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:
1 parent
0b78379b9b
commit
d67f8cc471
6 files changed
+896
No files matched your search
@@ -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}`)
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user