// Läufer für die Tests des Kellers (BotLeadList, keller/*): bündelt die Komponente mit esbuild und dem Mini-Läufer fakeReact (React und DOM gibt es unter Node nicht), // rendert in einen Baum aus { tag, props, children }; Eingaben, Klicks und Tasten ruft der Test von Hand auf, die Zeit (window.setTimeout) läuft von Hand, der Dienst // ist eine Attrappe mit offenen Versprechen. Gemeinsam für tests/unit/botleadList.test.mjs und tests/unit/hustlerKeller.test.mjs. import assert from 'node:assert/strict' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' import { fakeReact } from './fakeReact.mjs' export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') const importBundle = (out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`) export const settle = () => new Promise((resolve) => setImmediate(resolve)) // Die Komponente wird einmal gebündelt; jeder Test hängt sie neu ein (eigener Zustand) let bundle export const loadUi = () => (bundle ??= esbuild.build({ stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as BotLeadList } from './src/components/workers/BotLeadList.jsx'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReact], }).then(importBundle)) // ---------- Baum durchsuchen ---------- export const nodes = (node, match, out = []) => { if (node === null || typeof node === 'string') return out if (match(node)) out.push(node) for (const child of node.children) nodes(child, match, out) return out } export const textOf = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textOf).join('')) export const classesOf = (n) => String(n.props.className ?? '').split(' ').filter(Boolean) export const hasClass = (n, name) => classesOf(n).includes(name) export const byClass = (tree, name) => nodes(tree, (n) => hasClass(n, name)) // Knopf nach Beschriftung: aria-label oder sichtbarer Text export const buttons = (tree, label) => nodes(tree, (n) => n.tag === 'button' && (n.props['aria-label'] === label || textOf(n).trim() === label)) // alle hk-Klassen eines Baums export const allClasses = (tree) => { const set = new Set() nodes(tree, (n) => { for (const c of classesOf(n)) if (c.startsWith('hk-')) set.add(c); return false }) return set } // Dienst-Attrappe: jeder Aufruf von botleads ist ein offenes Versprechen, das der Test selbst auflöst export function fakeApi() { const calls = [] return { calls, botleads: (params) => new Promise((resolve, reject) => { calls.push({ params, resolve, reject }) }) } } // DOM-Attrappe für den Fokus: document mit body, activeElement und querySelector; ref an dem, was die Komponente anfasst, liefert __fakeRef. // Schlüssel eines Elements: „reiter“ (der aktive Raum-Reiter, aria-pressed), sonst die aria-label des Knopfs („Nächste Seite“, „Schließen“ …) oder sein Text, sonst der Tag. // Ein Element gilt nur, solange es im Baum steht (isConnected, wie im Browser); steht es beim nächsten Render nicht im Baum der Runde davor (Seitenzeile während des // Ladens), ist es danach ein neues Element, so wie React die Knöpfe neu einhängt. focus() zählt nur für ein eingehängtes Element, merkt sich Schlüssel und Argument // und setzt activeElement. Zeilen des Buchs gibt es über querySelector('[data-hk-zeile=""]') (Schlüssel „zeile “), wie der Browser sie der Akte liefert. export function fakeFocus() { const body = { name: 'body' } const doc = { body, documentElement: { name: 'html' }, activeElement: body } const f = { doc, body, focused: [], options: [], tree: () => null, view: null, made: new Map() } const keyOf = (tag, props) => { if (tag !== 'button') return tag if (props['aria-pressed'] !== undefined) return 'reiter' return String(props['aria-label'] ?? props.children) } const live = (tag, key) => nodes(f.tree(), (n) => n.tag === tag && keyOf(tag, n.props) === key).length > 0 const had = Object.hasOwn(globalThis, 'document') const saved = globalThis.document globalThis.document = doc globalThis.__fakeRef = (tag, props) => { const key = keyOf(tag, props) const known = f.made.get(key) if (!known || !live(tag, key)) { // während dieser Runde zeigt f.tree() noch die Runde davor f.made.set(key, { key, props, get disabled() { return this.props.disabled === true }, get isConnected() { return f.made.get(key) === this && live(tag, key) }, focus(options) { if (this.isConnected) { f.focused.push(key); f.options.push(options); doc.activeElement = this } }, }) } f.made.get(key).props = props return f.made.get(key) } doc.querySelector = (sel) => { const m = /^\[data-hk-zeile="(\d+)"\]$/.exec(sel) if (!m) return null const nr = Number(m[1]) if (nodes(f.tree(), (n) => n.tag === 'button' && n.props['data-hk-zeile'] === nr).length === 0) return null const key = `zeile ${nr}` return { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } } } // ein Element, das der Nutzer von Hand ansteuert (Suchfeld, Link …): eingehängt, bedienbar f.other = (name) => ({ name, isConnected: true, disabled: false, focus() { doc.activeElement = this } }) f.attach = (view) => { f.view = view; f.tree = () => view.tree() } // Der Nutzer drückt einen Knopf: der Browser gibt ihm den Fokus, dann läuft der Klick // (Knöpfe ohne ref, z. B. „Erneut versuchen“, sind nur so lange Element, wie ein Knopf dieser Beschriftung im Baum steht) f.press = async (label) => { doc.activeElement = f.made.get(label) ?? { name: label, disabled: false, get isConnected() { return buttons(f.tree(), label).length > 0 }, focus() { doc.activeElement = this } } await f.view.click(label) } // Der Nutzer klickt eine Zeile des Buchs (Fokus auf der Zeile, dann der Klick) f.pressRow = async (nr) => { const key = `zeile ${nr}` const el = { key, isConnected: true, disabled: false, focus(options) { f.focused.push(key); f.options.push(options); doc.activeElement = this } } doc.activeElement = el await f.view.openRow(nr) return el } f.restore = () => { if (had) globalThis.document = saved else delete globalThis.document delete globalThis.__fakeRef } return f } // ---------- Einträge des Dienstes (Form von GET /botleads) ---------- export const ITEM = { id: 'p1', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'https://beispiel-elektro.de/impressum', phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false, } export const PRAXIS = { ...ITEM, id: 'p2', room: 'aerzte', roomLabel: 'Ärzte & Praxen', name: 'Praxis Dr. Muster', isHealth: true, phone: '', phoneSource: '', sourceUrl: 'javascript:alert(1)', foundAt: '2026-10-18T20:00:00Z' } export const KUERZ = { ...ITEM, id: 'p3', name: '[gekürzt]', gekuerzt: true, phone: '', phoneSource: '', sourceUrl: '' } // ---------- Ein Lauf: Komponente einhängen, window.setTimeout von Hand, Props zwischendurch änderbar (rerender) ---------- export async function start(api, initial = {}) { const { __mount, jsx, BotLeadList } = await loadUi() const timers = new Map() let nextTimer = 1 const saved = globalThis.window globalThis.window = { setTimeout: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id }, clearTimeout: (id) => { timers.delete(id) }, } // stufe 6: Live-Daten (Stufen-Sperre, hustlerStufe.js: unterhalb von Stufe 6 ruft der Keller nichts ab und zeigt sein leeres Register); die Tests der Stufe 3 geben sie ausdrücklich an let props = { api, stufe: 6, ...initial } const mounted = __mount(jsx(() => jsx(BotLeadList, props), {})) const view = { timers, tree: () => mounted.tree(), text: () => textOf(view.tree()), setProps(next) { props = { ...props, ...next }; mounted.rerender() }, // Zeilen des Buchs (Knöpfe) und ihre Teile rows: () => nodes(view.tree(), (n) => n.tag === 'button' && hasClass(n, 'hk-zeile')), parts: (row) => { const zwei = byClass(row, 'hk-zwei-zeilen') return { name: textOf(byClass(row, 'hk-kl-name')[0]), sub: textOf(byClass(row, 'hk-klein')[0]), score: textOf(byClass(row, 'hk-kl-punkte-text')[0]), reason: textOf(zwei[0]), phone: textOf(zwei[1]), raumIcon: row.children[0].props.className, label: row.props['aria-label'], } }, // Raum-Reiter (Knöpfe mit aria-pressed) in der Reihenfolge der Leiste reiter: () => nodes(view.tree(), (n) => n.tag === 'button' && n.props['aria-pressed'] !== undefined), activeIndex: () => view.reiter().findIndex((r) => r.props['aria-pressed'] === true), search: () => nodes(view.tree(), (n) => n.tag === 'input')[0], errors: () => byClass(view.tree(), 'hk-kl-fehler').map((n) => ({ text: textOf(n), role: n.props.role })), async pick(index) { view.reiter()[index].props.onClick({}); await settle() }, async type(value) { view.search().props.onChange({ target: { value } }); await settle() }, async click(label) { const found = buttons(view.tree(), label) assert.equal(found.length, 1, `Knopf „${label}“ (gefunden: ${found.length})`) if (!found[0].props.disabled) found[0].props.onClick({ preventDefault() {} }) await settle() }, async openRow(nr) { view.rows()[nr].props.onClick({ preventDefault() {} }); await settle() }, disabled: (label) => buttons(view.tree(), label).map((n) => n.props.disabled === true), fireTimers() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } }, // Akte: der Dialog, seine Teile, Tasten am äußeren Rahmen akte: () => nodes(view.tree(), (n) => n.props.role === 'dialog')[0] ?? null, akteDl: () => { const dl = byClass(view.tree(), 'hk-kl-akte-dl')[0] const out = {} let name = null for (const c of dl ? dl.children : []) { if (c === null || typeof c === 'string') continue if (c.tag === 'dt') name = textOf(c) else if (c.tag === 'dd') out[name] = c } return out }, async key(key, extra = {}) { const ueber = byClass(view.tree(), 'hk-kl-akte-ueber')[0] assert.ok(ueber, 'keine Akte offen') const ev = { key, shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, currentTarget: ueber, prevented: false, preventDefault() { ev.prevented = true }, stopPropagation() {}, ...extra } ueber.props.onKeyDown(ev) await settle() return ev }, // der Dienst antwortet auf den n-ten Aufruf async answer(n, data) { api.calls[n].resolve(data); await settle() }, async fail(n, err) { api.calls[n].reject(err); await settle() }, lateUpdates: () => mounted.lateUpdates(), end() { mounted.unmount(); globalThis.window = saved }, } await settle() return view }