import test from 'node:test' import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import esbuild from 'esbuild' // Die React-Teile sind unter Node nicht ladbar (JSX, Router): geprüft wird der Quelltext (Muster adminRoute.test.mjs); die Logik // steckt in P3-T18. usePultFeed und PultList laufen wirklich: im Mini-Läufer anstelle von React (siehe unten), PultList zusätzlich als Text gerendert. const read = (p) => fs.readFileSync(new URL('../../' + p, import.meta.url), 'utf8') test('WorkersPage hängt den Reiter freigabe an hochhaus an und zeigt dort die PultList', () => { const s = read('src/pages/WorkersPage.jsx') assert.match(s, /id: 'hochhaus'/); assert.match(s, /id: 'freigabe'/); assert.match(s, /import PultList from/); assert.match(s, /]*api=\{api\}/) }) test('PultList: sichere Links, kein innerHTML, Entwurf bearbeitbar, Filter aus PULT_FILTERS', () => { const s = read('src/components/workers/PultList.jsx') assert.doesNotMatch(s, /dangerouslySetInnerHTML/) assert.match(s, /rel="noopener noreferrer"/); assert.match(s, /PULT_FILTERS/); assert.match(s, /copyAndOpen/) assert.doesNotMatch(s, /readOnly[^>]*wk-pult-draft|wk-pult-draft[^>]*readOnly/) }) test('usePultFeed: 30-s-Takt nur bei sichtbarem Tab', () => { const s = read('src/hooks/usePultFeed.js') assert.match(s, /30000/); assert.match(s, /document\.visibilityState/); assert.match(s, /visibilitychange/) }) test('CSS: wk-pult-Klassen und Handy-Umbruch; jede im JSX benutzte wk-Klasse ist definiert', () => { const css = read('src/styles/workers.css') assert.match(css, /\.wk-pult-row/); assert.match(css, /@media \(max-width: 860px\)[\s\S]*\.wk-pult-row/) for (const f of ['PultList', 'ProspectPanel']) { const p = 'src/components/workers/' + f + '.jsx'; if (fs.existsSync(new URL('../../' + p, import.meta.url))) for (const m of read(p).matchAll(/(? { const s = read('src/components/workers/PultList.jsx') assert.match(s, /api\.pultAction\(row\.id, 'claim'\)/); assert.match(s, /api\.pultAction\(row\.id, 'sent', \{ text \}\)/) assert.match(s, /api\.pultAction\(row\.id, 'discard', \{\}\)/); assert.match(s, /api\.pultAction\(row\.id, 'done'\)/) // Kopieren & öffnen: Text ist der Inhalt des Textfelds (nicht row.copyText), das Öffnen geschieht ohne Referrer und ohne Rückbezug assert.match(s, /copyAndOpen\(\{ text, url: row\.sourceHref, copy: copyTextChecked, open: openTab \}\)/) assert.match(s, /window\.open\(url, '_blank', 'noopener,noreferrer'\)/) assert.match(s, /Wirklich verwerfen\?/); assert.match(s, /CONFIRM_MS = 4000/) for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Erledigt', 'Originalanfrage', 'Nichts wartet auf dich', 'Details']) assert.ok(s.includes(t), t) assert.match(s, /className=\{`msg \$\{/); assert.match(s, /'ok'/); assert.match(s, /'err'/) // der Mail-Link trägt den bearbeiteten Text, nicht den gespeicherten Entwurf assert.match(s, /mailtoHref\(\{[\s\S]*?body: text \}\)/) // der Text erscheint nur als Textknoten assert.doesNotMatch(s, /innerHTML/) }) // ---- gemeinsame Bausteine der Läufer: EIN Mini-Läufer anstelle von React (Hooks, Zustand, Effekte, Baum aus { tag, props, children }) ---- // ---- und EINE Tabelle mit Attrappen für alles, was PultList von außen holt. usePultFeed und PultList laufen im selben Läufer. ---- 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')}`) const FAKE_REACT = ` export const Fragment = Symbol('fragment') export function jsx(type, props, key) { return { type, props: props ?? {}, key } } export const jsxs = jsx let current = null // Instanz des Bauteils, dessen Render gerade läuft let hookIndex = 0 const flat = (children, out = []) => { if (Array.isArray(children)) children.forEach((c) => flat(c, out)) else if (children !== null && children !== undefined && children !== false && children !== true) out.push(children) return out } // Hängt ein Element (Bauteile mit Hooks, darunter Tags) ein und rendert es; jedes Bauteil behält seinen Zustand unter seinem Pfad im Baum. export function __mount(element) { const owner = { render, effects: [], late: 0 } // late: Zustandsänderungen, die nach dem Abbau noch ankamen const instances = new Map() let tree = null let rendering = false let dirty = false const dispose = (inst) => { inst.alive = false for (const h of inst.hooks) if (h && typeof h.cleanup === 'function') h.cleanup() } const expand = (node, path, visited) => { if (typeof node === 'string' || typeof node === 'number') return String(node) const { type, props } = node if (typeof type === 'function') { visited.add(path) let inst = instances.get(path) if (!inst) { inst = { hooks: [], alive: true, owner }; instances.set(path, inst) } current = inst hookIndex = 0 const out = type(props) current = null return out === null || out === undefined || out === false ? null : expand(out, path + '/c', visited) } const children = flat(props.children).map((c, i) => expand(c, path + '/' + (c && c.key != null ? 'k:' + c.key : i), visited)) // ref-Objekt an einem Tag: wie React vor den Effekten gesetzt; das Element liefert der Test (globalThis.__fakeRef), sonst null if (typeof type === 'string' && props.ref && typeof props.ref === 'object') props.ref.current = globalThis.__fakeRef ? globalThis.__fakeRef(type, props) : null return { tag: type === Fragment ? 'fragment' : type, props, children } } function render() { if (rendering) { dirty = true; return } rendering = true do { dirty = false const visited = new Set() tree = expand(element, 'r', visited) for (const [path, inst] of [...instances]) { if (visited.has(path)) continue dispose(inst) instances.delete(path) } for (const run of owner.effects.splice(0)) run() } while (dirty) rendering = false } render() return { tree: () => tree, rerender: render, lateUpdates: () => owner.late, unmount: () => { for (const inst of instances.values()) dispose(inst); instances.clear() }, } } export function useState(initial) { const inst = current const i = hookIndex++ if (!inst.hooks[i]) inst.hooks[i] = { value: typeof initial === 'function' ? initial() : initial } const slot = inst.hooks[i] return [slot.value, (next) => { if (!inst.alive) { inst.owner.late += 1; return } const value = typeof next === 'function' ? next(slot.value) : next if (Object.is(value, slot.value)) return slot.value = value inst.owner.render() }] } export function useRef(initial) { const inst = current const i = hookIndex++ if (!inst.hooks[i]) inst.hooks[i] = { current: initial } return inst.hooks[i] } export function useCallback(fn, deps) { const inst = current const i = hookIndex++ const slot = inst.hooks[i] if (slot && deps.every((d, k) => Object.is(d, slot.deps[k]))) return slot.fn inst.hooks[i] = { fn, deps } return fn } export function useEffect(fn, deps) { const inst = current const i = hookIndex++ if (!inst.hooks[i]) inst.hooks[i] = { deps: undefined, cleanup: undefined, ran: false } const slot = inst.hooks[i] if (slot.ran && deps && slot.deps && deps.every((d, k) => Object.is(d, slot.deps[k]))) return inst.owner.effects.push(() => { if (typeof slot.cleanup === 'function') slot.cleanup() slot.deps = deps slot.ran = true slot.cleanup = fn() }) } ` const fakeReact = { name: 'fake-react', setup(build) { build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' })) build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT, loader: 'js' })) }, } // Was PultList von außen holt: der Feed (je Filter eine Antwort aus globalThis.__pultFeedFor), die Mitarbeiterliste, die Zwischenablage, das Panel (Dialog) const STUBS = { usePultFeed: 'export function usePultFeed({ statuses } = {}) { return globalThis.__pultFeedFor(statuses) }', useEmployees: 'export function useEmployees() { return { employees: globalThis.__pultEmployees ?? [] } }', CopyableCredential: 'export async function copyTextChecked() {}', ProspectPanel: 'export default function ProspectPanel() { return null }', // das Panel gehört nicht zu diesen Tests (workersProspect.test.mjs) } const stubs = { name: 'pult-stubs', setup(build) { build.onResolve({ filter: /hooks\/(usePultFeed|useEmployees)$|CopyableCredential$|\/ProspectPanel$/ }, (a) => ({ path: a.path.split('/').pop(), namespace: 'pult-stub' })) build.onLoad({ filter: /.*/, namespace: 'pult-stub' }, (a) => ({ contents: STUBS[a.path], loader: 'js' })) }, } // ---- usePultFeed läuft wirklich: im Mini-Läufer, falsche document/window mit von Hand weitergestelltem Takt ---- async function loadFeed() { const { usePultFeed, __mount, jsx } = await importBundle(await esbuild.build({ stdin: { contents: "export { usePultFeed } from './src/hooks/usePultFeed.js'\nexport { __mount, jsx } from 'react'", resolveDir: ROOT, loader: 'js' }, bundle: true, format: 'esm', write: false, platform: 'neutral', plugins: [fakeReact], })) // Der Hook läuft in einem Bauteil, das nichts zeichnet und sein Ergebnis festhält const mountHook = (hook) => { let result const mounted = __mount(jsx(() => { result = hook(); return null }, {})) return { result: () => result, rerender: mounted.rerender, lateUpdates: mounted.lateUpdates, unmount: mounted.unmount } } return { usePultFeed, mountHook } } // document und window nur für die Dauer eines Tests: Takt (setInterval) und visibilitychange werden von Hand ausgelöst function fakeBrowser() { const listeners = new Map() const timers = new Map() let nextTimer = 1 const doc = { visibilityState: 'visible', addEventListener: (type, fn) => listeners.set(type, [...(listeners.get(type) ?? []), fn]), removeEventListener: (type, fn) => listeners.set(type, (listeners.get(type) ?? []).filter((f) => f !== fn)), } const win = { setInterval: (fn, ms) => { const id = nextTimer++; timers.set(id, { fn, ms }); return id }, clearInterval: (id) => { timers.delete(id) }, } const saved = { document: globalThis.document, window: globalThis.window } globalThis.document = doc globalThis.window = win return { doc, timers, listenerCount: (type) => (listeners.get(type) ?? []).length, fire: (type) => { for (const fn of listeners.get(type) ?? []) fn() }, tick: () => { for (const t of [...timers.values()]) t.fn() }, restore: () => { globalThis.document = saved.document; globalThis.window = saved.window }, } } // api.pult-Attrappe: jeder Aufruf ist ein offenes Versprechen, das der Test selbst auflöst function fakeApi() { const calls = [] return { calls, pult: (status) => new Promise((resolve, reject) => { calls.push({ status, resolve, reject }) }), } } const settle = () => new Promise((resolve) => setImmediate(resolve)) const itemsOf = (...ids) => ({ items: ids.map((id) => ({ $id: id })) }) test('usePultFeed: lädt beim Start, zeigt die Einträge, deutscher Fehler bleibt neben den letzten Einträgen stehen', async () => { const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() const hook = mountHook(() => usePultFeed({ api, statuses: 'offen,in_arbeit' })) assert.deepEqual(api.calls.map((c) => c.status), ['offen,in_arbeit']) assert.deepEqual([hook.result().loading, hook.result().items, hook.result().error], [true, [], null]) api.calls[0].resolve(itemsOf('a', 'b')); await settle() assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id), hook.result().error], [false, ['a', 'b'], null]) // Takt: ein Abruf schlägt fehl, die Einträge bleiben, der Satz des Dienstes erscheint assert.equal([...b.timers.values()][0].ms, 30000) b.tick() api.calls[1].reject(Object.assign(new Error('Der Hustler-Dienst ist gerade nicht erreichbar.'), { status: 0 })); await settle() assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a', 'b'], 'Der Hustler-Dienst ist gerade nicht erreichbar.']) // der nächste Erfolg räumt den Fehler weg b.tick(); api.calls[2].resolve(itemsOf('a')); await settle() assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['a'], null]) hook.unmount() } finally { b.restore() } }) test('usePultFeed: Takt und Neuladen nur bei sichtbarem Tab; zurück im Vordergrund wird sofort geladen', async () => { const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) api.calls[0].resolve(itemsOf('a')); await settle() b.doc.visibilityState = 'hidden' b.tick(); b.fire('visibilitychange') // versteckt: weder Takt noch Wechsel-Ereignis rufen den Dienst assert.equal(api.calls.length, 1) b.doc.visibilityState = 'visible'; b.fire('visibilitychange') assert.equal(api.calls.length, 2) // zurück im Vordergrund: sofort b.tick() assert.equal(api.calls.length, 3) hook.unmount() assert.equal(b.timers.size, 0); assert.equal(b.listenerCount('visibilitychange'), 0) // aufgeräumt } finally { b.restore() } }) test('usePultFeed: nur die letzte Anfrage zählt (späte alte Antwort wird verworfen); refresh() holt sofort neu', async () => { const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) const first = api.calls[0] const refreshed = hook.result().refresh() // zweite Anfrage, während die erste noch läuft assert.equal(api.calls.length, 2) api.calls[1].resolve(itemsOf('neu')); await refreshed; await settle() first.resolve(itemsOf('alt')); await settle() // die ältere Antwort kommt zuletzt und darf nichts überschreiben assert.deepEqual(hook.result().items.map((i) => i.$id), ['neu']) // ein später Fehler der alten Anfrage zählt ebenfalls nicht b.tick(); const late = api.calls[2] b.tick(); api.calls[3].resolve(itemsOf('neuer')); await settle() late.reject(new Error('zu spät')); await settle() assert.deepEqual([hook.result().items.map((i) => i.$id), hook.result().error], [['neuer'], null]) hook.unmount() } finally { b.restore() } }) test('usePultFeed: Wechsel des Filters zeigt nie die Einträge des alten Filters und verwirft dessen späte Antwort', async () => { const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() let statuses = 'offen,in_arbeit' const hook = mountHook(() => usePultFeed({ api, statuses })) api.calls[0].resolve(itemsOf('a')); await settle() statuses = 'veraltet'; hook.rerender() assert.equal(api.calls.at(-1).status, 'veraltet') assert.deepEqual([hook.result().loading, hook.result().items], [true, []]) statuses = 'gesendet,verworfen,erledigt'; hook.rerender() // noch einmal gewechselt, bevor 'veraltet' antwortet api.calls.find((c) => c.status === 'veraltet').resolve(itemsOf('v')); await settle() assert.deepEqual([hook.result().loading, hook.result().items], [true, []]) api.calls.at(-1).resolve(itemsOf('e')); await settle() assert.deepEqual([hook.result().loading, hook.result().items.map((i) => i.$id)], [false, ['e']]) hook.unmount() } finally { b.restore() } }) test('usePultFeed: nach dem Abbau setzt eine späte Antwort nichts mehr', async () => { const { usePultFeed, mountHook } = await loadFeed() const b = fakeBrowser() try { const api = fakeApi() const hook = mountHook(() => usePultFeed({ api, statuses: 'offen' })) hook.unmount() api.calls[0].resolve(itemsOf('a')); await settle() assert.equal(hook.lateUpdates(), 0) // auch ein später Fehler setzt nichts const second = fakeApi() const again = mountHook(() => usePultFeed({ api: second, statuses: 'offen' })) again.unmount() second.calls[0].reject(new Error('zu spät')); await settle() assert.equal(again.lateUpdates(), 0) } finally { b.restore() } }) // ---- PultList wird mit echtem React auf einen Text gerendert (renderToStaticMarkup); Attrappen aus STUBS ---- async function loadList() { return importBundle(await esbuild.build({ stdin: { contents: "import { createElement } from 'react'\nimport { renderToStaticMarkup } from 'react-dom/server'\nimport PultList from './src/components/workers/PultList.jsx'\nexport const render = (props) => renderToStaticMarkup(createElement(PultList, props))", resolveDir: ROOT, loader: 'js', }, bundle: true, format: 'esm', platform: 'node', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [stubs], // react-dom/server ist CommonJS und holt sich Node-Module über require: im Data-URL-Modul gibt es keins banner: { js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(process.cwd() + '/');" }, })) } test('PultList (gerendert): Aktionen je Art und Status, Fremdtext nur als Text, sichere Links, Mail-Link mit genau einer Adresse', async () => { const { render } = await loadList() const now = Date.now() const iso = (ms) => new Date(ms).toISOString() const me = { id: 'u1', name: 'Justin' } const show = (items, extra = {}) => { globalThis.__pultFeedFor = () => ({ items, loading: false, error: null, refresh: async () => {}, ...extra }) globalThis.__pultEmployees = [{ userId: 'u2', displayName: 'Kenso' }] return render({ api: {}, me }) } const base = { $id: 'a', kind: 'plattform_antwort', status: 'offen', title: 'Suche Webdesigner', draft: 'Hallo', createdAt: iso(now - 3 * 3600e3), sourceUrl: 'https://www.kleinanzeigen.de/s-anzeige/x/1-2-3', prospect: { $id: 'p1', name: 'Verein', city: 'Namborn', postalCode: '66640', score: 72 } } try { // offene Plattform-Antwort: alle vier Aktionen, Link mit rel, Kurzinfo, Details; das Textfeld trägt den Entwurf let html = show([{ ...base, originalText: 'Original ', draft: 'Hallo ', title: 'T x' }]) for (const t of ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen', 'Details', '66640 Namborn · Score 72', 'vor 3 Std.']) assert.ok(html.includes(t), t) const erledigt = '' // der Filter „Erledigt“ steht ebenfalls auf der Seite: nur den Knopf der Zeile meinen assert.ok(!html.includes(erledigt)); assert.match(html, //) assert.match(html, /