diff --git a/scripts/workers-harness/build.mjs b/scripts/workers-harness/build.mjs new file mode 100644 index 0000000..afd27d1 --- /dev/null +++ b/scripts/workers-harness/build.mjs @@ -0,0 +1,46 @@ +// Baut den Prüfstand: node scripts/workers-harness/build.mjs +// 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 ') + 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}`) diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx new file mode 100644 index 0000000..680d841 --- /dev/null +++ b/scripts/workers-harness/entry.jsx @@ -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( + + +
+ + + , +) diff --git a/scripts/workers-harness/index.html b/scripts/workers-harness/index.html new file mode 100644 index 0000000..c0b0ae5 --- /dev/null +++ b/scripts/workers-harness/index.html @@ -0,0 +1,14 @@ + + + + + + + + Hustler-Prüfstand + + +
+ + + diff --git a/scripts/workers-harness/scripted-api.js b/scripts/workers-harness/scripted-api.js new file mode 100644 index 0000000..846b968 --- /dev/null +++ b/scripts/workers-harness/scripted-api.js @@ -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 {} + }, + } +} diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs new file mode 100644 index 0000000..bb534ef --- /dev/null +++ b/scripts/workers-harness/shot.cjs @@ -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 +// = Ausgabe von build.mjs, = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo. +// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1. +'use strict' +const { createRequire } = require('module') +const path = require('path') +const fs = require('fs') +const os = require('os') +const http = require('http') +const req = createRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/') +const { chromium } = req('playwright-core') + +const repo = path.resolve(__dirname, '../..') +const distArg = process.argv[2] +const outArg = process.argv[3] +if (!distArg || !outArg) { console.error('usage: node shot.cjs '); process.exit(2) } +const dist = path.resolve(distArg) +const out = path.resolve(outArg) +const tmp = path.resolve(process.env.TMPDIR || os.tmpdir()) +const okBase = [tmp, path.join(repo, '.superpowers')] +if (!okBase.some((b) => out.startsWith(b + path.sep))) { console.error('refusing: outDir must be below ' + okBase.join(' or ')); process.exit(2) } +if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { console.error('refusing: outDir must be new or empty'); process.exit(2) } +if (!fs.existsSync(path.join(dist, 'index.html'))) { console.error('refusing: no index.html in ' + dist); process.exit(2) } +fs.mkdirSync(out, { recursive: true }) + +// Namen und Modelle aus der gemeinsamen Roster-Fixture (CommonJS kann den ESM-Quelltext nicht laden) +const roster = JSON.parse(fs.readFileSync(path.join(repo, 'tests/fixtures/workers-roster.json'), 'utf8')).roles +const MODEL = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' } +const FONT_URL = '/fonts/PixelifySans-Variable.woff2' +const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell' +const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.' + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' } +const fontLog = [] + +function serve() { + const server = http.createServer((rq, rs) => { + let rel + try { rel = decodeURIComponent(new URL(rq.url, 'http://x').pathname) } catch { rs.writeHead(400).end(); return } + const fonts = rel.startsWith('/fonts/') + const root = fonts ? path.join(repo, 'public', 'fonts') : dist + const file = path.resolve(root, '.' + (fonts ? rel.slice('/fonts'.length) : (rel === '/' ? '/index.html' : rel))) + if (rq.method !== 'GET' || !file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + if (fonts) fontLog.push({ url: rel, status: 404 }) + rs.writeHead(404).end('not found') + return + } + if (fonts) fontLog.push({ url: rel, status: 200 }) + rs.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }) + fs.createReadStream(file).pipe(rs) + }) + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))) +} + +const problems = [] +const errors = [] +const checks = {} +const metrics = {} +const shots = [] + +function check(name, ok, detail) { + checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) + if (!ok) problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) +const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim() + +async function shoot(page, name, options = {}) { + const file = path.join(out, name) + await page.screenshot({ path: file, ...options }) + shots.push(file) +} + +// Neuer Kontext mit Fehlersammlung; rAF wird mitgezählt (eine Schleife = höchstens ein wartender Rückruf) +async function newPage(browser, label, contextOptions) { + const context = await browser.newContext(contextOptions) + await context.addInitScript(() => { + const raf = window.requestAnimationFrame.bind(window) + const caf = window.cancelAnimationFrame.bind(window) + const waiting = new Set() + window.__raf = { waiting, calls: 0 } + window.requestAnimationFrame = (cb) => { + window.__raf.calls += 1 + const id = raf((t) => { waiting.delete(id); cb(t) }) + waiting.add(id) + return id + } + window.cancelAnimationFrame = (id) => { waiting.delete(id); caf(id) } + }) + const page = await context.newPage() + page.setDefaultTimeout(20000) + page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e))) + page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) }) + page.on('requestfailed', (r) => errors.push(label + ' requestfailed: ' + r.url())) + page.on('response', (r) => { + if (r.url().endsWith(FONT_URL)) metrics.fontStatus = r.status() + if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url()) + }) + return { context, page } +} + +async function ready(page, url) { + await page.goto(url) + await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) + await page.evaluate(() => document.fonts.load('600 12px "Pixelify Sans"')) + await page.waitForFunction(() => window.__wk && window.__wk.stage.current && window.__wk.stage.current.stats().frames > 0) + await page.evaluate(() => document.fonts.ready) +} + +const stats = (page) => page.evaluate(() => window.__wk.stage.current.stats()) +const topOfPage = (page) => page.evaluate(() => window.scrollTo(0, 0)) + +async function pickFromList(page, id, name) { + await page.locator(`[data-wk-worker="${id}"]`).click() + await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, name) +} + +// ---------- Szenario A: Desktop 1280 x 900 ---------- + +async function desktop(browser, base) { + const { context, page } = await newPage(browser, 'desktop', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + const t0 = Date.now() + await ready(page, base + '/index.html') + await sleep(500) + await topOfPage(page) + + // 1. Schrift: geladen, Status 200, document.fonts.check + check('schrift geladen', await page.evaluate(() => document.fonts.check('600 12px "Pixelify Sans"'))) + check('schrift mit 200 geliefert', metrics.fontStatus === 200, metrics.fontStatus) + check('keine Warnzeile ohne ?halted=1', (await page.locator('.msg.err[role="alert"]').count()) === 0) + + // a) erstes Bild: Hochhaus komplett (Bühne mindestens 560 px breit, Zoom 1) + const box = await page.locator('.wk-canvas').boundingBox() + const first = await stats(page) + metrics.desktopStage = { width: Math.round(box.width), height: Math.round(box.height), top: Math.round(box.y), zoom: first.zoom } + check('desktop: ganzes Hochhaus im Bild (Zoom 1, Bühne >= 560 px, Unterkante im Fenster)', first.zoom === 1 && box.width >= 560 && box.y + box.height <= 900, metrics.desktopStage) + await shoot(page, 'desktop-start.png') + + // 2. 19 Knöpfe; Klick auf jeden zeigt den Namen aus ROLES und den vollen Modellnamen + check('19 Knöpfe', (await page.locator('[data-wk-worker]').count()) === 19) + const wrong = [] + for (const r of roster) { + await pickFromList(page, r.workerId, r.name) + const name = await textOf(page.locator('.wk-name')) + const chip = await textOf(page.locator('.wk-chip')) + if (name !== r.name || chip !== MODEL[r.model]) wrong.push({ id: r.workerId, name, chip }) + } + check('Klick auf jeden Knopf: Name und Modell-Chip stimmen', wrong.length === 0, wrong) + + // 3. sechs Figuren auf der Leinwand anklicken + await page.locator('button[aria-label="Karte schließen"]').click() + const hits = [] + for (const id of ['otto', 'kim', 'rita', 'bea', 'bob', 'arne']) { + await topOfPage(page) + const pos = await page.evaluate((who) => window.__wk.stage.current.locate(who), id) + const cb = await page.locator('.wk-canvas').boundingBox() + if (!pos) { hits.push({ id, locate: null }); continue } + await page.mouse.click(cb.x + pos.x, cb.y + pos.y) + const want = roster.find((r) => r.workerId === id).name + let got = '' + try { + await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, want, { timeout: 3000 }) + got = want + } catch { + got = await textOf(page.locator('.wk-name').first()).catch(() => '') + } + if (got !== want) hits.push({ id, want, got }) + } + check('Klick auf sechs Figuren trifft die Figur (locate)', hits.length === 0, hits) + + // 4. Spur von kim: javascript:-Zeile nur Text, https-Zeile ein sicherer Link + await pickFromList(page, 'kim', 'Kim') + await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12) + check('Spur hat 12 Zeilen', (await page.locator('.wk-trace li').count()) === 12) + check('kein Link mit javascript:', (await page.locator('.wk-trace a[href^="javascript:" i]').count()) === 0) + const fund = page.locator('.wk-trace a[href="https://example.org/fund"]') + check('https-Zeile ist ein Link', (await fund.count()) === 1) + if ((await fund.count()) === 1) { + check('https-Link: rel noopener noreferrer, target _blank', (await fund.getAttribute('rel')) === 'noopener noreferrer' && (await fund.getAttribute('target')) === '_blank') + } + const unsafeRow = page.locator('.wk-trace li', { hasText: 'Link mit Skript abgelehnt' }) + check('javascript:-Zeile steht als Text', (await unsafeRow.count()) === 1 && (await unsafeRow.locator('a').count()) === 0) + + // Karte offen (Bea: Sonnet-Chip, Spur) für das Bild + await pickFromList(page, 'bea', 'Bea') + await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12) + await topOfPage(page) + await shoot(page, 'desktop-karte.png') + await page.locator('button[aria-label="Karte schließen"]').click() + + // 5. Pausenknopf und Einstellungen + const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true }) + await pauseBtn.click() + await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor() + check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true) + await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click() + await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor() + check('Pausenknopf: zurück auf „Alles anhalten“', true) + + await page.getByRole('button', { name: 'Einstellungen', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' }) + await dialog.waitFor() + await dialog.getByLabel('Tagesgrenze (USD)').waitFor() + check('Einstellungen: Dialog „Hustler-Einstellungen“ mit Feld „Tagesgrenze (USD)“', true) + check('Einstellungen ohne Notbremse: kein Block „Versand gestoppt“', (await page.locator('.wk-halt').count()) === 0) + await sleep(600) // der Dialog blendet ein + await shoot(page, 'desktop-einstellungen.png') + await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('Einstellungen: „Abbrechen“ schließt den Dialog', true) + + // 6. nach 20 s Skriptlauf: Bild, Zeichenzeit, Bilder pro Sekunde, eine einzige Schleife + await topOfPage(page) + const wait = 20000 - (Date.now() - t0) + if (wait > 0) await sleep(wait) + await shoot(page, 'desktop-nach-20s.png') + const timed = () => page.evaluate(() => ({ ...window.__wk.stage.current.stats(), at: performance.now() })) + // Messfenster von zwei Sekunden: Bilder je Sekunde und wartende rAF-Rückrufe (eine Schleife = höchstens einer) + const sample = async () => { + const a = await timed() + const raf = [] + for (let i = 0; i < 20; i += 1) { + raf.push(await page.evaluate(() => window.__raf.waiting.size)) + await sleep(100) + } + const b = await timed() + const seconds = (b.at - a.at) / 1000 + return { frames: b.frames - a.frames, seconds, fps: (b.frames - a.frames) / seconds, avgDrawMs: b.avgDrawMs, rafWaitingMax: Math.max(...raf), raf } + } + const busy = await sample() + const dbg = await page.evaluate(() => window.__wk.stage.current.debug()) + metrics.fpsDuringScript = Math.round(busy.fps * 10) / 10 + metrics.avgDrawMs = Math.round(busy.avgDrawMs * 100) / 100 + metrics.rafWaitingMax = busy.rafWaitingMax + metrics.pending = dbg.pending + if (!(busy.avgDrawMs <= 25)) console.warn('WARNUNG: avgDrawMs ' + busy.avgDrawMs.toFixed(2) + ' > 25 (software-gerendert)') + check('Schleife läuft (Bilder wachsen)', busy.frames > 0, busy.frames) + check('StrictMode: genau eine Schleife (höchstens ein wartender rAF-Rückruf)', busy.rafWaitingMax <= 1, busy.raf) + check('debug().pending >= 0', dbg.pending >= 0, dbg.pending) + // Nach dem Skript (Ende bei 45,8 s, letzter Zustandsabruf bei 45 s) kommen keine Daten mehr: dann zählt nur die Schleife. + // Beim Eintreffen von Zustand oder Ereignissen zeichnet die Szene sofort ein Bild extra, das ist keine Schleife. + const rest = 49000 - (Date.now() - t0) + if (rest > 0) await sleep(rest) + const idle = await sample() + metrics.fpsIdle = Math.round(idle.fps * 10) / 10 + metrics.fpsWindowSeconds = Math.round(idle.seconds * 100) / 100 + // FRAME_MS 33 ergibt höchstens 30,3 Bilder je Sekunde; ein Bild Messkante kommt dazu + check('Bilder pro Sekunde höchstens 30 (FRAME_MS 33, Ruhe nach dem Skript)', idle.fps <= 1000 / 33 + 1 / idle.seconds, { fps: metrics.fpsIdle, window: metrics.fpsWindowSeconds }) + check('Schleife läuft auch in Ruhe mit etwa 30 Bildern je Sekunde', idle.fps >= 20, idle.fps) + await context.close() +} + +// ---------- Szenario B: Blasen, Zeichen und Symbole bei doppelter Auflösung ---------- + +async function bubbles(browser, base) { + const { context, page } = await newPage(browser, 'blasen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 }) + await ready(page, base + '/index.html') + await sleep(400) + await topOfPage(page) + const cb = await page.locator('.wk-canvas').boundingBox() + const clip = { x: cb.x, y: cb.y, width: cb.width, height: cb.height } + await shoot(page, 'desktop-hochhaus-2x.png', { clip }) + // Echte Engine, selbst gebaute Ereignisse: ’ ü % und die längste erlaubte Blase (20 Zeichen) gleichzeitig + const at = Date.now() + const make = (n, workerId, kind, text, extra) => ({ id: 'probe-' + n, ts: new Date(at + n).toISOString(), workerId, kind, text, link: null, to: null, prospectId: null, costUsd: 0, meta: null, ...extra }) + await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [ + make(1, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'), + make(2, 'bob', 'bau_start', 'Vorschau wird gebaut'), + make(3, 'theo', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }), + ]) + await sleep(700) + await shoot(page, 'desktop-blasen-2x.png', { clip }) + await sleep(2600) + await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [ + make(4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück', { meta: { round: 1 } }), + make(5, 'lea', 'gelesen', 'Läden gelesen', { meta: { count: 123456789012 } }), + ]) + await sleep(700) + await shoot(page, 'desktop-blasen-2x-b.png', { clip }) + await context.close() +} + +// ---------- Szenario C: Handy 390 x 844 ---------- + +async function phone(browser, base) { + const { context, page } = await newPage(browser, 'handy', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) + await ready(page, base + '/index.html') + await sleep(500) + const width = await page.evaluate(() => document.documentElement.scrollWidth) + metrics.phoneScrollWidth = width + check('handy: kein seitliches Scrollen (scrollWidth <= 390)', width <= 390, width) + await page.locator('.wk-stage').scrollIntoViewIfNeeded() + await sleep(200) + const zoomBtn = page.getByRole('button', { name: 'Vergrößern', exact: true }) + check('handy: Zoom-Knöpfe sichtbar', (await zoomBtn.isVisible()) && (await page.getByRole('button', { name: 'Verkleinern', exact: true }).isVisible())) + await shoot(page, 'handy.png') + const z0 = (await stats(page)).zoom + await zoomBtn.click() + await sleep(300) + const z1 = (await stats(page)).zoom + metrics.phoneZoom = [Math.round(z0 * 100) / 100, Math.round(z1 * 100) / 100] + check('handy: „+“ erhöht den Zoom', z1 > z0, [z0, z1]) + await shoot(page, 'handy-zoom.png') + await shoot(page, 'handy-ganz.png', { fullPage: true }) + 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) }) diff --git a/tests/unit/workersHarness.test.mjs b/tests/unit/workersHarness.test.mjs new file mode 100644 index 0000000..e3ce9ef --- /dev/null +++ b/tests/unit/workersHarness.test.mjs @@ -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 }) +})