diff --git a/scripts/workers-harness/build.mjs b/scripts/workers-harness/build.mjs index afd27d1..676609e 100644 --- a/scripts/workers-harness/build.mjs +++ b/scripts/workers-harness/build.mjs @@ -2,6 +2,9 @@ // 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. +// Drei Module der App hängen an Appwrite (Anmeldung, Mitarbeiterliste, Sitzungs-JWT) und werden nur im Prüfstand ausgetauscht, damit die +// echte Seite WorkersPage (Reiter Hochhaus und Freigabe) samt PultList und ProspectPanel ohne Netz läuft: AuthContext -> stub-auth.js, +// useEmployees -> stub-employees.js, workersClient -> stub-client.js. Am Quelltext der App ändert das nichts. import esbuild from 'esbuild' import fs from 'node:fs' import os from 'node:os' @@ -29,6 +32,20 @@ if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { } fs.mkdirSync(out, { recursive: true }) +const STUBS = [ + { filter: /(^|\/)context\/AuthContext(\.jsx?)?$/, file: 'stub-auth.js' }, + { filter: /(^|\/)hooks\/useEmployees(\.jsx?)?$/, file: 'stub-employees.js' }, + { filter: /(^|\/)lib\/workersClient(\.jsx?)?$/, file: 'stub-client.js' }, +] +const stubPlugin = { + name: 'harness-stubs', + setup(build) { + for (const stub of STUBS) { + build.onResolve({ filter: stub.filter }, () => ({ path: path.join(here, stub.file) })) + } + }, +} + const result = await esbuild.build({ entryPoints: [path.join(here, 'entry.jsx')], outfile: path.join(out, 'harness.js'), @@ -37,6 +54,7 @@ const result = await esbuild.build({ platform: 'browser', jsx: 'automatic', external: ['/fonts/*'], + plugins: [stubPlugin], define: { 'process.env.NODE_ENV': '"development"' }, logLevel: 'warning', metafile: true, diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx index 35fb044..9a71cb1 100644 --- a/scripts/workers-harness/entry.jsx +++ b/scripts/workers-harness/entry.jsx @@ -4,6 +4,9 @@ // ?fault=none|down|session|hang schaltet zwischen Board und Skript-API den echten API-Client (createWorkersApi) mit einer Fetch-Weiche // (scripted-fetch.js): window.__wk.control.fault lässt sich zur Laufzeit auf null | 'down' | 'session' | 'hang' setzen (Ausfall-Szenario). // Ohne den Parameter bekommt das Board die Skript-API direkt (Szenarien A bis E). +// ?page=1 zeigt statt des Boards die echte Seite WorkersPage (Reiter Hochhaus und Freigabe, PultList, ProspectPanel); AuthContext, +// useEmployees und workersClient sind dort durch stub-*.js ersetzt (build.mjs). ?tab=freigabe öffnet den Reiter gleich (Deep-Link). +// Mit ?page=1&fault=none läuft die Seite über den echten API-Client und die Fetch-Weiche (Pult, Aktionen, Interessent). import { StrictMode, createRef } from 'react' import { createRoot } from 'react-dom/client' import { MemoryRouter } from 'react-router-dom' @@ -11,9 +14,11 @@ 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 WorkersPage from '../../src/pages/WorkersPage' import { createWorkersApi } from '../../src/lib/workersApi.js' import { createScriptedApi } from './scripted-api.js' import { createScriptedFetch } from './scripted-fetch.js' +import { holder } from './harness-bridge.js' const params = new URLSearchParams(window.location.search) const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1' }) @@ -32,18 +37,27 @@ const api = fault === null timeoutMs: 1500, }) window.__wk = { stage, api: scripted, control } +holder.api = api + +const pageMode = params.get('page') === '1' +const tab = params.get('tab') +const entries = [pageMode && tab ? `/hustler?tab=${encodeURIComponent(tab)}` : '/hustler'] createRoot(document.getElementById('root')).render( - +
diff --git a/scripts/workers-harness/harness-bridge.js b/scripts/workers-harness/harness-bridge.js new file mode 100644 index 0000000..3f2da8f --- /dev/null +++ b/scripts/workers-harness/harness-bridge.js @@ -0,0 +1,3 @@ +// Brücke zwischen entry.jsx und den Austausch-Modulen (stub-*.js): entry.jsx legt die Skript-API (oder den echten API-Client über +// der Fetch-Weiche) hier ab, stub-client.js reicht Aufrufe der Seite WorkersPage daran weiter. Kein Netz, kein Appwrite. +export const holder = { api: null } diff --git a/scripts/workers-harness/scripted-api.js b/scripts/workers-harness/scripted-api.js index 846b968..f0141c0 100644 --- a/scripts/workers-harness/scripted-api.js +++ b/scripts/workers-harness/scripted-api.js @@ -2,6 +2,10 @@ // 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. +// Freigabe-Pult und Interessent (P3): pult, pultAction und prospect antworten mit festen Daten in der Form der Team-Routen des Dienstes +// (workers-dev/src/api/anfragenRoutes.js) und verhalten sich wie dessen Regeln (pult.js): übernehmen, als gesendet markieren, verwerfen, +// erledigen, 409 mit deutschem Satz bei fremder Übernahme. Die Schalter `faults` lassen einzelne Aktionen mit einem Status und einem +// deutschen Satz scheitern; `pultCalls`, `prospectCalls` und `pauseCalls` halten fest, was die Oberfläche geschickt hat. import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js' import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js' @@ -132,6 +136,180 @@ const TRACE = Object.freeze([ ['auftrag', 'Auftrag von Otto angenommen', null], ]) +// ---------- Freigabe-Pult und Interessenten (P3) ---------- +// Form wie die Team-Routen des Dienstes (workers-dev/src/api/anfragenRoutes.js): Pult-Einträge mit allen Feldern aus §4.11 (nur die gesetzten), +// dazu `prospect` als Kurzfassung (kein Telefon, kein Anfragetext); die Ansicht des Interessenten nur mit den Feldern der Whitelist. +// Telefonnummern gibt es in diesen Daten nirgends: die Prüfung "kein Telefon im Bild" ist damit eine Prüfung der Oberfläche, nicht der Daten. +const ME = 'mensch:u-me' // der angemeldete Prüfstand-Nutzer (stub-auth.js: Kennung u-me, Name Kenso) +const COLLEAGUE = 'mensch:u-kollege' // der Kollege, den stub-employees.js als „Justin“ kennt +const HOUR_MS = 3600 * 1000 +const DAY_MS = 24 * HOUR_MS +const PULT_STATUS = ['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'] +const PULT_LIMIT = 50 +const STATUS_ERROR = `Der Status ist ungültig. Erlaubt sind: ${PULT_STATUS.join(', ')}.` +const REPLY_KINDS = ['plattform_antwort', 'mail_antwort'] +// Felder der Kurzfassung in der Liste (kurzSicht) und der Ansicht des Interessenten (interessentSicht): kein Telefon, keine Nachweise +const PROSPECT_SHORT = ['entrance', 'name', 'source', 'sourceUrl', 'city', 'postalCode', 'email', 'score', 'scoreReason', 'requestedAt', 'status'] +const PROSPECT_VIEW = [ + 'entrance', 'room', 'foundBy', 'source', 'sourceUrl', 'foundAt', 'name', 'contactName', 'email', 'city', 'postalCode', + 'requestText', 'requestedAt', 'score', 'scoreReason', 'status', 'threadId', 'previewId', 'offerId', 'customerId', +] +const pick = (record, keys) => Object.fromEntries(keys.filter((key) => record[key] !== undefined).map((key) => [key, record[key]])) + +const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1' +const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1' +const NORD_URL = 'https://www.freelancermap.de/projekt/wordpress-relaunch-agentur' +const FOTO_URL = 'https://www.kleinanzeigen.de/s-anzeige/portfolio-seite-fotograf/2870000003-1-1' +const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein' + +// Lange Wörter ohne Leerzeichen: sie müssen am Handy umbrechen, statt die Seite zu verbreitern +const LONG_WORD = 'https://www.sv-namborn.example/vereinsleben/abteilungen/fussball/jugend/spielplan-und-trainingszeiten-saison-2026-2027-alle-mannschaften' + +function pultFixtures(base) { + const at = (ms) => isoOf(base + ms) + + const items = [ + { + $id: 'pult-mail', + kind: 'mail_antwort', + prospectId: 'p-mail', + threadId: 'thr-mail', + title: 'Anfrage: Vereinswebsite für den SV Namborn', + originalText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht. Siehe auch ${LONG_WORD}\nBudget bis 1.500 Euro.`, + draft: 'Guten Tag Herr Beispiel,\n\nvielen Dank für Ihre Anfrage. Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Vereinsseite.\n\nBeste Grüße\nKenso', + status: 'offen', + createdAt: at(-2.5 * HOUR_MS), + }, + { + $id: 'pult-plat', + kind: 'plattform_antwort', + prospectId: 'p-plat', + title: 'Kleinanzeigen: Website für Friseursalon gesucht', + originalText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.', + draft: 'Hallo, ich habe Ihre Anzeige gesehen. Wir bauen Ihnen kostenlos eine erste Vorschau, gern mit Preisliste und Terminanfrage.', + sourceUrl: HAIR_URL, + status: 'offen', + createdAt: at(-5 * HOUR_MS), + staleAt: at(20 * HOUR_MS), + }, + { + $id: 'pult-kollege', + kind: 'plattform_antwort', + prospectId: 'p-kollege', + title: 'Kleinanzeigen: Homepage für Handwerksbetrieb', + originalText: 'Brauche eine Homepage für meinen Meisterbetrieb.', + draft: 'Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage, ohne Kosten und ohne Verpflichtung.', + sourceUrl: WEBER_URL, + status: 'in_arbeit', + claimedBy: COLLEAGUE, + claimedAt: at(-1 * HOUR_MS), + createdAt: at(-8 * HOUR_MS), + staleAt: at(3 * DAY_MS), + }, + { + $id: 'pult-unsicher', + kind: 'unsicher', + title: 'Unsichere Antwort: bitte prüfen', + originalText: 'Nora war sich bei der Einordnung einer Antwort nicht sicher und bittet um einen Blick.', + status: 'offen', + createdAt: at(-26 * HOUR_MS), + }, + { + $id: 'pult-alt', + kind: 'plattform_antwort', + prospectId: 'p-alt', + title: 'freelancermap: WordPress-Relaunch', + originalText: 'Relaunch einer bestehenden WordPress-Seite.', + draft: 'Guten Tag, wir übernehmen den Relaunch gern und zeigen vorab eine Vorschau.', + sourceUrl: NORD_URL, + status: 'veraltet', + createdAt: at(-6 * DAY_MS), + staleAt: at(-3 * DAY_MS), + }, + { + $id: 'pult-gesendet', + kind: 'plattform_antwort', + prospectId: 'p-gesendet', + title: 'Kleinanzeigen: Fotograf sucht Portfolio-Seite', + draft: 'Hallo, wir bauen Ihnen gern eine Portfolio-Seite. Eine erste Vorschau gibt es kostenlos.', + sourceUrl: FOTO_URL, + status: 'gesendet', + claimedBy: ME, + claimedAt: at(-2 * DAY_MS + HOUR_MS), + doneBy: ME, + doneAt: at(-1 * DAY_MS), + createdAt: at(-2 * DAY_MS), + }, + { + $id: 'pult-erledigt', + kind: 'beschwerde', + title: 'Beschwerde über eine Vorschau', + originalText: 'Der Kunde findet die Vorschau unvollständig.', + status: 'erledigt', + doneBy: COLLEAGUE, + doneAt: at(-3 * DAY_MS), + createdAt: at(-4 * DAY_MS), + }, + ] + + // Interessenten: Kurzfassung (kurzSicht) und Zusatzfelder der Ansicht (interessentSicht); nur Felder aus der Whitelist + const prospects = { + 'p-mail': { + entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'freelancermap-alarm', sourceUrl: SV_URL, + foundAt: at(-3 * HOUR_MS), name: 'SV Namborn', contactName: 'Hans Beispiel', email: 'vorstand@sv-namborn.example', + city: 'Namborn', postalCode: '66640', + requestText: `Hallo,\nwir suchen jemanden, der unsere Vereinsseite neu macht.\n\nBudget bis 1.500 Euro, Start im November. Mehr dazu: ${LONG_WORD}`, + requestedAt: at(-3 * HOUR_MS), score: 84, scoreReason: 'Klarer Auftrag, Budget genannt, Region passt.', + status: 'bewertet', threadId: 'thr-mail', previewId: 'prev-mail', + }, + 'p-plat': { + entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: HAIR_URL, + foundAt: at(-5 * HOUR_MS), name: 'Friseursalon Haarmonie', city: 'Kaiserslautern', postalCode: '67655', + requestText: 'Suche jemanden für eine einfache Website mit Preisliste und Terminanfrage.\nBitte mit Referenzen melden.', + requestedAt: at(-5 * HOUR_MS), score: 72, scoreReason: 'Passt zum Angebot, Budget unklar.', status: 'bewertet', + }, + 'p-kollege': { + entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: WEBER_URL, + foundAt: at(-8 * HOUR_MS), name: 'Meisterbetrieb Weber', city: 'Landstuhl', postalCode: '66849', status: 'gefunden', + }, + 'p-alt': { + entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'freelancermap-alarm', sourceUrl: NORD_URL, + foundAt: at(-6 * DAY_MS), name: 'Agentur Nord', city: 'Hamburg', postalCode: '20095', score: 65, status: 'bewertet', + scoreReason: 'Relaunch, aber weit weg.', + }, + 'p-gesendet': { + entrance: 'anfrage', room: 'anfragen', foundBy: 'rita', source: 'kleinanzeigen-alarm', sourceUrl: FOTO_URL, + foundAt: at(-2 * DAY_MS), name: 'Foto Lichtblick', city: 'Mainz', postalCode: '55116', score: 78, status: 'kontaktiert', + }, + } + + // Faden, Nachrichten und Vorschau gibt es nur für den Mail-Interessenten (zwei Nachrichten: eine eingegangene, eine von einem Kollegen) + const threads = { + 'thr-mail': { + $id: 'thr-mail', prospectId: 'p-mail', channel: 'mail', toEmail: 'vorstand@sv-namborn.example', subject: 'Re: SV Namborn', + status: 'wartet_auf_uns', lastInboundAt: at(-1.5 * HOUR_MS), followupSent: false, reminderSent: false, createdAt: at(-3 * HOUR_MS), + }, + } + const messages = { + 'thr-mail': [ + { + $id: 'msg-1', direction: 'in', author: 'kunde', subject: 'Anfrage Vereinswebsite', channel: 'mail', classification: 'frage', + body: `Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?\nDie Seite ist heute unter ${LONG_WORD} erreichbar.`, + sentAt: at(-3 * HOUR_MS), createdAt: at(-3 * HOUR_MS), + }, + { + $id: 'msg-2', direction: 'out', author: COLLEAGUE, subject: 'Re: SV Namborn', channel: 'mail', + body: 'Gern. Wir melden uns mit einer ersten Vorschau.', + sentAt: at(-2 * HOUR_MS), createdAt: at(-2 * HOUR_MS), + }, + ], + } + const previews = { + 'prev-mail': { $id: 'prev-mail', prospectId: 'p-mail', builder: 'bob', status: 'fertig', round: 1, createdAt: at(-1 * HOUR_MS), deleteAfter: at(60 * DAY_MS) }, + } + return { items, prospects, threads, messages, previews } +} + const clone = (v) => JSON.parse(JSON.stringify(v)) const isoOf = (ms) => new Date(ms).toISOString() @@ -182,6 +360,12 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) let paused = false const settings = defaultSettings(halted === true) const saved = [] + const fx = pultFixtures(base) + const pultItems = fx.items + const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null } // je { status, message } oder null + const pultCalls = [] + const prospectCalls = [] + const pauseCalls = [] // Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige const all = SCRIPT.map((s, i) => ({ @@ -227,9 +411,18 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) } const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 }) + const httpError = (status, message) => Object.assign(new Error(message), { status }) + const failure = (fault) => httpError(fault.status, fault.message) + const nowIsoString = () => isoOf(base + elapsed()) + const shortOf = (id) => (Object.hasOwn(fx.prospects, id) ? { $id: id, ...pick(fx.prospects[id], PROSPECT_SHORT) } : null) - return { + const api = { saved, + faults, + pultCalls, + prospectCalls, + pauseCalls, + pultEmpty: false, // true: das Pult ist leer (Leerzustand) async state() { return clone(buildState()) @@ -265,6 +458,7 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) }, async pause(next) { + pauseCalls.push(Boolean(next)) paused = Boolean(next) return { paused } }, @@ -282,20 +476,76 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) return clone({ ...settings, paused }) }, - async pult() { - return { items: [] } + // GET /pult?status=…: neueste zuerst, höchstens 50, mit der Kurzfassung des Interessenten (null ohne Interessent) + async pult(status = 'offen,in_arbeit') { + if (faults.pult) throw failure(faults.pult) + const list = [...new Set((Array.isArray(status) ? status : String(status).split(',')).map((entry) => String(entry).trim()))] + if (list.some((entry) => !PULT_STATUS.includes(entry))) throw httpError(400, STATUS_ERROR) + const rows = api.pultEmpty + ? [] + : pultItems.filter((item) => list.includes(item.status)).sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, PULT_LIMIT) + return clone({ items: rows.map((item) => ({ ...item, prospect: shortOf(item.prospectId) })) }) }, - async pultAction() { - return {} + // POST /pult/:id/: Regeln wie pult.js (claimPult, markSent, discard, donePult); Antwort { item } + async pultAction(id, action, body) { + pultCalls.push({ id, action, body: body === undefined ? undefined : clone(body) }) + if (!['claim', 'sent', 'discard', 'done'].includes(action)) throw httpError(404, 'Unbekannte Aktion.') + if (faults[action]) throw failure(faults[action]) + const item = pultItems.find((entry) => entry.$id === id) + if (!item) throw httpError(404, 'Eintrag nicht gefunden.') + const editable = ['offen', 'in_arbeit', 'veraltet'].includes(item.status) + const prospect = Object.hasOwn(fx.prospects, item.prospectId) ? fx.prospects[item.prospectId] : null + const now = nowIsoString() + + if (action === 'claim') { + if (item.status === 'in_arbeit') { + if (item.claimedBy !== ME) throw httpError(409, 'Wird schon von einem Kollegen bearbeitet.') + } else if (item.status === 'offen' || item.status === 'veraltet') { + Object.assign(item, { status: 'in_arbeit', claimedBy: ME, claimedAt: now }) + } else { + throw httpError(409, 'Schon erledigt.') + } + } else if (action === 'sent') { + if (!REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Dieser Eintrag ist keine Antwort zum Absenden.') + if (!editable) throw httpError(409, 'Schon erledigt.') + const text = body && typeof body.text === 'string' ? body.text.trim() : (item.draft ?? '').trim() + if (text === '') throw httpError(400, 'Der Text der Antwort fehlt.') + Object.assign(item, { status: 'gesendet', doneBy: ME, doneAt: now }) + if (prospect && (prospect.status === 'gefunden' || prospect.status === 'bewertet')) prospect.status = 'kontaktiert' + } else if (action === 'discard') { + if (!editable) throw httpError(409, 'Schon erledigt.') + Object.assign(item, { status: 'verworfen', doneBy: ME, doneAt: now }) + if (prospect && prospect.status === 'bewertet') prospect.status = 'verloren' + } else { + if (REPLY_KINDS.includes(item.kind)) throw httpError(409, 'Antworten schließt man mit „Als gesendet“ oder „Verwerfen“ ab.') + if (!editable) throw httpError(409, 'Schon erledigt.') + Object.assign(item, { status: 'erledigt', doneBy: ME, doneAt: now }) + } + return clone({ item }) }, async botleads() { return { items: [], total: 0 } }, - async prospect() { - return {} + // GET /prospect/:id: { prospect (nur Felder der Whitelist), thread, messages, preview, reply }; Faden und Vorschau fehlen als null + async prospect(id) { + prospectCalls.push(id) + if (faults.prospect) throw failure(faults.prospect) + if (!Object.hasOwn(fx.prospects, id)) throw httpError(404, 'Interessent nicht gefunden.') + const record = fx.prospects[id] + const thread = record.threadId && Object.hasOwn(fx.threads, record.threadId) ? fx.threads[record.threadId] : null + const preview = record.previewId && Object.hasOwn(fx.previews, record.previewId) ? fx.previews[record.previewId] : null + const reply = pultItems.find((item) => item.prospectId === id && REPLY_KINDS.includes(item.kind)) ?? null + return clone({ + prospect: { $id: id, ...pick(record, PROSPECT_VIEW) }, + thread, + messages: thread && Object.hasOwn(fx.messages, thread.$id) ? fx.messages[thread.$id] : [], + preview, + reply, + }) }, } + return api } diff --git a/scripts/workers-harness/scripted-fetch.js b/scripts/workers-harness/scripted-fetch.js index 345f578..0a38640 100644 --- a/scripts/workers-harness/scripted-fetch.js +++ b/scripts/workers-harness/scripted-fetch.js @@ -2,6 +2,8 @@ // beantwortet sie mit der Skript-API. Damit läuft im Prüfstand dieselbe Kette wie im Betrieb (API-Client, Feed, Board), und die // Ausfälle lassen sich schalten: control.fault = null (gesund) | 'down' (503 ohne JSON wie eine Fehlerseite des Proxys) | // 'session' (401) | 'hang' (die Antwort kommt nie, wie auf einer toten Verbindung). Kein Netz, kein Appwrite. +// Seit P3 auch Pult (GET /pult, POST /pult/:id/) und Interessent (GET /prospect/:id): Fehler der Skript-API (status + deutscher +// Satz) kommen als { error } mit diesem Status an, wie vom Dienst. // control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann. const reply = (status, body) => ({ @@ -34,6 +36,12 @@ export function createScriptedFetch(api, control) { if (method === 'POST' && path === '/pause') return reply(200, await api.pause(body.paused)) if (method === 'GET' && path === '/settings') return reply(200, await api.settings()) if (method === 'PUT' && path === '/settings') return reply(200, await api.saveSettings(body)) + // Freigabe-Pult und Interessent (P3); ohne ?status= gilt wie im Dienst offen,in_arbeit + if (method === 'GET' && path === '/pult') return reply(200, await api.pult(parsed.searchParams.get('status') ?? undefined)) + const action = /^\/pult\/([^/]+)\/([^/]+)$/.exec(path) + if (method === 'POST' && action) return reply(200, await api.pultAction(decodeURIComponent(action[1]), action[2], body)) + const prospect = /^\/prospect\/([^/]+)$/.exec(path) + if (method === 'GET' && prospect) return reply(200, await api.prospect(decodeURIComponent(prospect[1]))) return reply(404, { error: 'Unbekannter Pfad im Prüfstand.' }) } catch (err) { return reply(err && err.status ? err.status : 500, { error: err && err.message ? err.message : 'Fehler im Prüfstand.' }) diff --git a/scripts/workers-harness/shot.cjs b/scripts/workers-harness/shot.cjs index 9a11d10..51c8a93 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -4,6 +4,10 @@ // 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. +// Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten +// mit Rückfrage, J Handy mit Freigabe und Panel (P3). Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der +// Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=freigabe,interessent (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für +// die Arbeit am Prüfstand; die Abnahme läuft immer vollständig). 'use strict' const { createRequire } = require('module') const path = require('path') @@ -195,6 +199,8 @@ async function desktop(browser, base) { // 5. Pausenknopf und Einstellungen const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true }) await pauseBtn.click() + // Anhalten betrifft alle Mitarbeiter und fragt nach (P2-T21): erst „Anhalten“ im Dialog „Alles anhalten?“ pausiert + await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).getByRole('button', { name: 'Anhalten', exact: true }).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() @@ -471,19 +477,511 @@ async function outage(browser, base) { await hang.context.close() } +// ---------- P3: Freigabe-Pult, Interessenten-Panel, Anhalten mit Rückfrage ---------- +// Die Seite läuft als echte WorkersPage (?page=1) über den echten API-Client und die Fetch-Weiche (&fault=none); Anmeldung, Mitarbeiterliste +// und Sitzungs-JWT sind ersetzt (build.mjs). Zwischenablage und window.open sind ersetzt und halten fest, was die Oberfläche tut +// (window.__clip, window.__opened). Die Schalter der Skript-API (window.__wk.api.faults, .pultEmpty, .pultCalls) steuern 409, 400, Ausfall und Leerzustand. + +const T_MAIL = 'Anfrage: Vereinswebsite für den SV Namborn' +const T_PLAT = 'Kleinanzeigen: Website für Friseursalon gesucht' +const T_KOLL = 'Kleinanzeigen: Homepage für Handwerksbetrieb' +const T_UNS = 'Unsichere Antwort: bitte prüfen' +const T_ALT = 'freelancermap: WordPress-Relaunch' +const T_GES = 'Kleinanzeigen: Fotograf sucht Portfolio-Seite' +const T_ERL = 'Beschwerde über eine Vorschau' +const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1' +const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1' +const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein' +const PULT_URL = '/index.html?page=1&fault=none' +const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.' +const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.' +const TOO_LONG = 'Der Text ist zu lang (höchstens 8000 Zeichen).' +const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.' +// Telefon: das Wort oder eine Nummer (+49 / Vorwahl mit 0, Trennzeichen, weitere Ziffern); Postleitzahlen und Beträge passen nicht darauf +const PHONE_WORD = /telefon|phone|\btel\.|handynummer|mobilnummer/i +const PHONE_NUMBER = /\+49|\b0\d{2,5}[\s/-]\d{3,}/ + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const rowOf = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: title }) }) +const rowTitles = (page) => page.locator('.wk-pult-title').allTextContents() +const finishedLine = (page) => page.locator('.wk-pult-filters ~ .msg') +const setPultFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value]) +const pultCalls = (page) => page.evaluate(() => window.__wk.api.pultCalls) +const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 }) +const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() })) +const waitTitles = (page, titles, timeout = 5000) => page.waitForFunction( + (want) => JSON.stringify(Array.from(document.querySelectorAll('.wk-pult-title')).map((el) => el.textContent)) === JSON.stringify(want), + titles, + { timeout }, +) +const activeProbe = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-probe') : null)) +const activeTag = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.tagName : null)) + +// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden der Seite) +async function newPultPage(browser, label, contextOptions) { + const made = await newPage(browser, label, contextOptions) + // Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren + // wartet, öffnet später als 300 ms nach dem Klick und verliert die Nutzer-Geste. window.open hält Zeit und Geste fest. + await made.context.addInitScript(() => { + window.__clip = [] + window.__opened = [] + window.__openMeta = [] + window.__lastClickAt = 0 + document.addEventListener('click', () => { window.__lastClickAt = performance.now() }, true) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: (text) => { window.__clip.push(text); return new Promise((resolve) => setTimeout(resolve, 300)) } }, + }) + window.open = (...args) => { + window.__opened.push(args) + window.__openMeta.push({ sinceClick: performance.now() - window.__lastClickAt, active: navigator.userActivation ? navigator.userActivation.isActive : null }) + return null + } + }) + return made +} + +// Reiter „Freigabe“ per Deep-Link öffnen und warten, bis die vier wartenden Einträge und die Mitarbeiternamen da sind +async function openPult(page, base) { + await page.goto(base + PULT_URL + '&tab=freigabe') + await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4) + await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin')) + await page.evaluate(() => document.fonts.ready) +} + +// ---------- Szenario G: Freigabe (Desktop 1280 x 900) ---------- + +async function freigabe(browser, base) { + const { context, page } = await newPultPage(browser, 'freigabe', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + + // 1. Reiterleiste: erst ab zwei Reitern, Start auf „Hochhaus“, Wechsel auf „Freigabe“ + await page.goto(base + PULT_URL) + await page.getByRole('tab').first().waitFor() + await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) + const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim()) + check('freigabe: Reiterleiste mit „Hochhaus“ und „Freigabe“', same(tabNames, ['Hochhaus', 'Freigabe']), tabNames) + check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true') + await page.getByRole('tab', { name: 'Freigabe', exact: true }).click() + await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4) + await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin')) + await page.evaluate(() => document.fonts.ready) + check('freigabe: nach dem Klick ist „Freigabe“ gewählt und das Hochhaus weg', (await page.getByRole('tab', { name: 'Freigabe', exact: true }).getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0) + + // 2. Filter und Liste + const filterButtons = page.locator('.wk-pult-filters .seg button') + check('freigabe: Filter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt', same(await filterButtons.allTextContents(), ['Wartet', 'Veraltet', 'Erledigt']) && same(await filterButtons.evaluateAll((els) => els.map((el) => el.getAttribute('aria-pressed'))), ['true', 'false', 'false'])) + const first = await rowTitles(page) + check('freigabe: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(first, [T_MAIL, T_PLAT, T_KOLL, T_UNS]), first) + + const mail = rowOf(page, T_MAIL) + const plat = rowOf(page, T_PLAT) + const koll = rowOf(page, T_KOLL) + const uns = rowOf(page, T_UNS) + const buttonNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim())) + + const platMeta = await textOf(plat.locator('.wk-pult-meta')) + check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta) + check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage')) + const link = plat.getByRole('link', { name: 'Anzeige öffnen', exact: true }) + check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer') + check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(plat)) + const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') + check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo) + check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0) + await mail.getByLabel('Entwurf der Antwort').fill('Neuer Text für die Mail.') + const mailEdited = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href') + check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited) + check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0) + check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns)) + await topOfPage(page) + await shoot(page, 'desktop-freigabe-wartet.png') + await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true }) + + // 3. Kollege hat schon übernommen: Kopieren & öffnen kopiert und öffnet, der Dienst antwortet 409, die Zeile zeigt den Namen + await koll.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click() + const kollNote = koll.locator('.msg') + await kollNote.waitFor() + const kollText = await textOf(kollNote) + check('freigabe: 409 beim Übernehmen: Hinweis „Wird schon von Justin bearbeitet.“ (zusammen mit „Kopiert und geöffnet.“)', kollText === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.', kollText) + check('freigabe: 409-Hinweis ist eine rote Zeile mit role=alert', (await kollNote.getAttribute('class')).includes('err') && (await kollNote.getAttribute('role')) === 'alert') + const kollStub = await stubState(page) + check('freigabe: Kollegen-Zeile: Text kopiert und Anzeige geöffnet (trotz 409)', kollStub.clip.length === 1 && kollStub.clip[0].startsWith('Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage') && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), kollStub) + check('freigabe: Kollegen-Zeile bleibt „übernommen von Justin“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin')) + await shoot(page, 'desktop-freigabe-409-kollege.png') + await resetStubs(page) + + // 4. Kopieren & öffnen mit bearbeitetem Text: kopiert den Text aus dem Feld, öffnet die Anzeige, übernimmt den Eintrag für mich + const area = plat.getByLabel('Entwurf der Antwort') + const draft = await area.inputValue() + const edited = draft + '\nPS: Bearbeitet im Prüfstand.' + await area.fill(edited) + await plat.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click() + const platNote = plat.locator('.msg') + await platNote.waitFor() + check('freigabe: Kopieren & öffnen: Meldung „Kopiert und geöffnet.“ (grün, role=status)', (await textOf(platNote)) === 'Kopiert und geöffnet.' && (await platNote.getAttribute('class')).includes('ok') && (await platNote.getAttribute('role')) === 'status', await textOf(platNote)) + const platStub = await stubState(page) + check('freigabe: Kopieren & öffnen kopiert den bearbeiteten Text aus dem Feld', same(platStub.clip, [edited]), platStub.clip) + check('freigabe: Kopieren & öffnen öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(platStub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), platStub.opened) + check('freigabe: Kopieren & öffnen öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', platStub.meta.length === 1 && platStub.meta[0].sinceClick < 150 && platStub.meta[0].active === true, platStub.meta) + await page.waitForFunction((t) => { + const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t)) + return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso') + }, T_PLAT) + const platCalls = (await pultCalls(page)).filter((c) => c.id === 'pult-plat') + check('freigabe: Kopieren & öffnen übernimmt den Eintrag (claim) und er steht „übernommen von Kenso“', platCalls.length >= 1 && platCalls[0].action === 'claim' && (await plat.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, platCalls) + check('freigabe: der bearbeitete Text bleibt nach dem Neuladen der Liste im Feld', (await area.inputValue()) === edited) + await shoot(page, 'desktop-freigabe-kopiert.png') + + // 5. Übernehmen: 409 vom Dienst (Schalter) zeigt den Satz, danach klappt es + await setPultFault(page, 'claim', { status: 409, message: CONFLICT }) + await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() + const mailNote = mail.locator('.msg') + await mailNote.waitFor() + check('freigabe: Übernehmen mit 409: der Satz des Dienstes steht in der Zeile', (await textOf(mailNote)) === CONFLICT && (await mailNote.getAttribute('role')) === 'alert', await textOf(mailNote)) + check('freigabe: nach dem 409 ist der Eintrag nicht übernommen (Knopf bleibt, kein „übernommen von“)', (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 1 && !(await textOf(mail.locator('.wk-pult-meta'))).includes('übernommen von')) + await shoot(page, 'desktop-freigabe-409-uebernehmen.png') + await setPultFault(page, 'claim', null) + await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() + await page.waitForFunction((t) => { + const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t)) + return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso') + }, T_MAIL) + check('freigabe: Übernehmen: „in Arbeit“ von mir (übernommen von Kenso), Meldung „Du kümmerst dich darum.“, Knopf weg', (await textOf(mail.locator('.msg'))) === 'Du kümmerst dich darum.' && (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, await textOf(mail.locator('.msg'))) + await shoot(page, 'desktop-freigabe-uebernommen.png') + + // 6. Als gesendet markieren: Fehler 400 mit deutschem Satz, dann der Erfolg mit dem bearbeiteten Text + await setPultFault(page, 'sent', { status: 400, message: TOO_LONG }) + await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click() + await plat.locator('.msg.err').waitFor() + check('freigabe: Fehler 400: der deutsche Satz des Dienstes steht in der Zeile (role=alert), die Zeile bleibt', (await textOf(plat.locator('.msg.err'))) === TOO_LONG && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await rowTitles(page)).includes(T_PLAT), await textOf(plat.locator('.msg.err'))) + await shoot(page, 'desktop-freigabe-400.png') + await setPultFault(page, 'sent', null) + await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click() + await finishedLine(page).first().waitFor() + check('freigabe: gesendet: Meldung oben „…“ als gesendet markiert. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T_PLAT}“ als gesendet markiert.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && !(await rowTitles(page)).includes(T_PLAT), await textOf(finishedLine(page).first())) + const sentCall = (await pultCalls(page)).filter((c) => c.id === 'pult-plat' && c.action === 'sent').pop() + check('freigabe: gesendet schickt den bearbeiteten Text (body.text)', sentCall !== undefined && sentCall.body && sentCall.body.text === edited, sentCall) + await shoot(page, 'desktop-freigabe-gesendet.png') + + // 7. Erledigt (Art ohne Antwort) + await uns.getByRole('button', { name: 'Erledigt', exact: true }).click() + await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ erledigt.`, T_UNS) + check('freigabe: Erledigt: Meldung oben, die Zeile ist weg', !(await rowTitles(page)).includes(T_UNS), await rowTitles(page)) + + // 8. Verwerfen: erster Klick fragt nach, die Frage verfällt nach 4 s, zwei Klicks verwerfen; die Oberfläche schickt keinen Grund + const discardBtn = mail.locator('.wk-pult-actions').getByRole('button', { name: /^(Verwerfen|Wirklich verwerfen\?)$/ }) + await discardBtn.click() + check('freigabe: Verwerfen: erster Klick fragt „Wirklich verwerfen?“, noch keine Aktion am Dienst', (await textOf(discardBtn)) === 'Wirklich verwerfen?' && (await pultCalls(page)).filter((c) => c.action === 'discard').length === 0) + await shoot(page, 'desktop-freigabe-verwerfen-frage.png') + await sleep(4400) + check('freigabe: Verwerfen: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await textOf(discardBtn)) === 'Verwerfen') + await discardBtn.click() + await discardBtn.click() + await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ verworfen.`, T_MAIL) + const discards = (await pultCalls(page)).filter((c) => c.action === 'discard') + check('freigabe: Verwerfen: genau ein Aufruf, Meldung oben „…“ verworfen., Zeile weg', discards.length === 1 && discards[0].id === 'pult-mail' && !(await rowTitles(page)).includes(T_MAIL), discards) + check('freigabe: Verwerfen schickt keinen Grund (Body {}); die Oberfläche hat kein Feld dafür', same(discards[0] && discards[0].body, {}), discards[0]) + const afterAll = await rowTitles(page) + check('freigabe: „Wartet“ zeigt am Ende nur den Eintrag des Kollegen', same(afterAll, [T_KOLL]), afterAll) + + // 9. Filter „Veraltet“, „Erledigt“ + await filterButtons.nth(1).click() + await waitTitles(page, [T_ALT]) + check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true') + check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0) + check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT))) + await shoot(page, 'desktop-freigabe-veraltet.png') + await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click() + await page.locator('.empty').waitFor() + const emptyOld = await textOf(page.locator('.empty')) + check('freigabe: Leerzustand „Veraltet“: „Nichts ist veraltet“ mit Hinweis', emptyOld.startsWith('Nichts ist veraltet') && emptyOld.includes('Einträge, die zu lange liegen'), emptyOld) + await shoot(page, 'desktop-freigabe-leer-veraltet.png') + + await filterButtons.nth(2).click() + await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL]) + check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), ohne Aktionsknöpfe', (await page.locator('.wk-pult-actions').count()) === 0) + await shoot(page, 'desktop-freigabe-erledigt.png') + + // 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“ + await page.evaluate(() => { window.__wk.api.pultEmpty = true }) + await filterButtons.nth(0).click() + await page.locator('.empty').waitFor() + const emptyWait = await textOf(page.locator('.empty')) + check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait) + await shoot(page, 'desktop-freigabe-leer.png') + await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN }) + await filterButtons.nth(1).click() + await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor() + const emptyDown = await textOf(page.locator('.empty')) + check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown) + await shoot(page, 'desktop-freigabe-ausfall.png') + await setPultFault(page, 'pult', null) + await page.getByRole('button', { name: 'Neu laden', exact: true }).click() + await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet')) + check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true) + await page.evaluate(() => { window.__wk.api.pultEmpty = false }) + await filterButtons.nth(0).click() + await waitTitles(page, [T_KOLL, T_ALT]) + check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true) + await context.close() +} + +// ---------- Szenario H: Interessenten-Panel (Desktop) ---------- + +async function interessent(browser, base) { + const { context, page } = await newPultPage(browser, 'interessent', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await openPult(page, base) + + // 1. „Details“ öffnet das Panel als Dialog + const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true }) + await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) + await opener.click() + const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true }) + await dialog.waitFor() + await dialog.locator('.wk-prospect').waitFor() + await sleep(600) // der Dialog blendet ein + check('interessent: „Details“ öffnet einen Dialog (role=dialog, aria-modal) mit dem Namen als Titel', (await dialog.getAttribute('aria-modal')) === 'true') + // Im Entwicklungsmodus hängt StrictMode jeden Effekt zweimal ein: ein oder zwei Abfragen, aber immer nur dieser Interessent + const asked = await page.evaluate(() => window.__wk.api.prospectCalls) + check('interessent: das Panel fragt den Interessenten p-mail ab (und keinen anderen)', asked.length >= 1 && asked.every((id) => id === 'p-mail'), asked) + check('interessent: der Fokus liegt beim Öffnen im Dialog', await dialog.evaluate((el) => el.contains(document.activeElement))) + const scoreParts = await dialog.locator('.wk-prospect-score').evaluate((el) => Array.from(el.children).map((child) => child.textContent.trim())) + check('interessent: Score als Badge „Score 84 · hoch“ mit Begründung', same(scoreParts, ['Score 84 · hoch', 'Klarer Auftrag, Budget genannt, Region passt.']), scoreParts) + const facts = await dialog.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim())) + check('interessent: Fakten Quelle, Gefunden, Ort, Status (ohne Telefon)', same(facts, ['Quelle', 'freelancermap', 'Gefunden', 'vor 3 Std.', 'Ort', '66640 Namborn', 'Status', 'Bewertet']), facts) + const request = dialog.locator('.wk-prospect-section').first() + const requestText = request.locator('.wk-pult-text') + check('interessent: Anfragetext als Text mit erhaltenen Zeilenumbrüchen (pre-wrap)', (await requestText.textContent()).includes('Vereinsseite neu macht.\n\nBudget bis 1.500 Euro') && (await requestText.evaluate((el) => getComputedStyle(el).whiteSpace)) === 'pre-wrap') + const open = request.getByRole('link', { name: 'Anfrage öffnen', exact: true }) + check('interessent: „Anfrage öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await open.getAttribute('href')) === SV_URL && (await open.getAttribute('target')) === '_blank' && (await open.getAttribute('rel')) === 'noopener noreferrer') + const msgs = await dialog.locator('li.wk-prospect-msg').evaluateAll((els) => els.map((el) => ({ who: el.querySelector('strong').textContent, when: el.querySelector('.wk-prospect-msg-head span').textContent, body: el.querySelector('p').textContent }))) + check('interessent: zwei Nachrichten: „Interessent“ (eingegangen) und „Justin“ (Mitarbeiter, Name aus der Liste), mit Alter', msgs.length === 2 && msgs[0].who === 'Interessent' && msgs[1].who === 'Justin' && msgs[0].when === 'vor 3 Std.' && msgs[1].when === 'vor 2 Std.' && msgs[0].body.startsWith('Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?'), msgs) + const previewLine = await textOf(dialog.locator('.wk-prospect-section').last().locator('p.muted')) + check('interessent: Vorschau-Zeile „Vorschau: fertig · Runde 1 · erstellt vor 1 Std.“', previewLine === 'Vorschau: fertig · Runde 1 · erstellt vor 1 Std.', previewLine) + const html = await page.evaluate(() => document.documentElement.outerHTML) + const text = await page.evaluate(() => document.body.innerText) + check('interessent: nirgends im Bild ein Telefon (weder Wort noch Nummer, weder in Text noch im HTML)', !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text), { word: (html.match(PHONE_WORD) || [])[0], number: (html.match(PHONE_NUMBER) || [])[0] }) + check('interessent: keine Mail-Rohdaten im Bild (messageId, inReplyTo, references)', !/messageId|inReplyTo|referencesJson/.test(html)) + await shoot(page, 'desktop-interessent-panel.png') + + // 2. Esc schließt, der Fokus kehrt auf „Details“ zurück + await page.keyboard.press('Escape') + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('interessent: Esc schließt das Panel', true) + check('interessent: nach Esc steht der Fokus wieder auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) + + // 3. „Schließen“ im Fuß und das Kreuz schließen ebenfalls und geben den Fokus zurück + await opener.click() + await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor() + await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).last().click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('interessent: „Schließen“ im Fuß schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) + await opener.click() + await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor() + await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).first().click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) + + // 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf + await rowOf(page, T_KOLL).getByRole('button', { name: 'Details', exact: true }).click() + const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true }) + await dialog2.waitFor() + await dialog2.locator('.wk-prospect').waitFor() + const body2 = await textOf(dialog2.locator('.wk-prospect')) + check('interessent: ohne Score „Noch nicht bewertet“, ohne Anfragetext und Verlauf die Leerzeilen, keine Vorschau-Zeile', body2.includes('Noch nicht bewertet') && body2.includes('Kein Anfragetext gespeichert.') && body2.includes('Noch keine Nachrichten.') && !body2.includes('Vorschau'), body2) + const facts2 = await dialog2.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim())) + check('interessent: Fakten Kleinanzeigen / 66849 Landstuhl / Gefunden', same(facts2, ['Quelle', 'Kleinanzeigen', 'Gefunden', 'vor 8 Std.', 'Ort', '66849 Landstuhl', 'Status', 'Gefunden']), facts2) + await sleep(600) + await shoot(page, 'desktop-interessent-leer.png') + await page.keyboard.press('Escape') + await page.getByRole('dialog').waitFor({ state: 'detached' }) + + // 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten + await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' }) + await rowOf(page, T_PLAT).getByRole('button', { name: 'Details', exact: true }).click() + const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true }) + await dialog3.getByRole('alert').waitFor() + check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3)) + await sleep(600) + await shoot(page, 'desktop-interessent-fehler.png') + await setPultFault(page, 'prospect', null) + await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).click() + const dialog4 = page.getByRole('dialog', { name: 'Friseursalon Haarmonie', exact: true }) + await dialog4.locator('.wk-prospect').waitFor() + check('interessent: „Neu laden“ holt den Interessenten (Score 72 · mittel)', (await textOf(dialog4.locator('.wk-prospect-score'))).startsWith('Score 72 · mittel'), await textOf(dialog4.locator('.wk-prospect-score'))) + await page.keyboard.press('Escape') + await page.getByRole('dialog').waitFor({ state: 'detached' }) + await context.close() +} + +// ---------- Szenario I: Alles anhalten mit Rückfrage (Desktop) ---------- + +async function anhalten(browser, base) { + const { context, page } = await newPage(browser, 'anhalten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + await ready(page, base + '/index.html') + await sleep(400) + await topOfPage(page) + const status = () => textOf(page.locator('.wk-status')) + const calls = () => page.evaluate(() => window.__wk.api.pauseCalls) + const pauseBtn = page.locator('.wk-toolbar').getByRole('button', { name: 'Alles anhalten', exact: true }) + + // 1. Rückfrage statt sofortigem Anhalten + await pauseBtn.evaluate((el) => el.setAttribute('data-probe', 'pause-button')) + await pauseBtn.click() + const dialog = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }) + await dialog.waitFor() + check('anhalten: „Alles anhalten“ öffnet den Dialog „Alles anhalten?“ mit dem Satz zu den Workern', (await textOf(dialog.locator('.dialog-body'))) === HALT_QUESTION, await textOf(dialog.locator('.dialog-body'))) + check('anhalten: Dialog mit den Knöpfen „Abbrechen“ und „Anhalten“', same(await dialog.locator('.dialog-foot button').allTextContents(), ['Abbrechen', 'Anhalten'])) + check('anhalten: vor der Bestätigung ist nichts angehalten (kein Aufruf, Status „Läuft“)', same(await calls(), []) && (await status()) === 'Läuft', { calls: await calls(), status: await status() }) + await sleep(600) + await shoot(page, 'desktop-anhalten-frage.png') + + // 2. Abbrechen tut nichts, der Fokus kehrt auf den Knopf zurück + await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('anhalten: „Abbrechen“ schließt den Dialog, nichts wird angehalten', same(await calls(), []) && (await status()) === 'Läuft' && (await pauseBtn.textContent()) === 'Alles anhalten') + check('anhalten: nach „Abbrechen“ steht der Fokus wieder auf „Alles anhalten“', (await activeProbe(page)) === 'pause-button', await activeTag(page)) + + // 3. Esc verhält sich wie Abbrechen + await pauseBtn.click() + await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).waitFor() + await page.keyboard.press('Escape') + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('anhalten: Esc schließt den Dialog, nichts wird angehalten, Fokus zurück auf „Alles anhalten“', same(await calls(), []) && (await activeProbe(page)) === 'pause-button', { calls: await calls(), focus: await activeTag(page) }) + + // 4. Anhalten hält an + await pauseBtn.click() + const dialog2 = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }) + await dialog2.waitFor() + await dialog2.getByRole('button', { name: 'Anhalten', exact: true }).click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor() + await page.waitForFunction(() => document.querySelector('.wk-status') && document.querySelector('.wk-status').textContent.startsWith('Angehalten')) + check('anhalten: „Anhalten“ pausiert genau einmal (Aufruf pause(true)), Status „Angehalten: …“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await status()) === 'Angehalten: Es wird nichts gesendet und nichts gebaut.', { calls: await calls(), status: await status() }) + await sleep(500) + const focusTag = await activeTag(page) + const focusText = await page.evaluate(() => (document.activeElement ? (document.activeElement.textContent || '').trim().slice(0, 40) : null)) + check('anhalten: nach „Anhalten“ steht der Fokus nicht auf (Tastatur-Nutzer verlieren sonst ihre Stelle)', focusTag !== null && focusTag !== 'BODY', { tag: focusTag, text: focusText }) + await topOfPage(page) + await shoot(page, 'desktop-anhalten-angehalten.png') + + // 5. Fortsetzen geht ohne Rückfrage + await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click() + await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor() + check('anhalten: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false), Status „Läuft“)', (await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await status()) === 'Läuft', { calls: await calls(), status: await status() }) + await context.close() +} + +// ---------- Szenario J: Handy 390 x 844 für Freigabe und Panel ---------- + +async function handyFreigabe(browser, base) { + const { context, page } = await newPultPage(browser, 'handy-freigabe', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) + await openPult(page, base) + await sleep(300) + const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) + const w0 = await width() + metrics.phoneFreigabe = w0 + check('handy-freigabe: Freigabe ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0) + const bar = await page.evaluate(() => { + const out = {} + for (const [name, sel] of [['tabs', '.tabs'], ['filter', '.wk-pult-filters .seg']]) { + const r = document.querySelector(sel).getBoundingClientRect() + out[name] = { left: Math.round(r.left), right: Math.round(r.right) } + } + return out + }) + check('handy-freigabe: Reiterleiste und Filter liegen im Fenster', bar.tabs.left >= 0 && bar.tabs.right <= 390 && bar.filter.left >= 0 && bar.filter.right <= 390, bar) + await topOfPage(page) + await shoot(page, 'handy-freigabe.png') + await shoot(page, 'handy-freigabe-ganz.png', { fullPage: true }) + + // Jede Schaltfläche und jeder Link der Aktionszeilen: im Fenster, nicht verdeckt, mindestens 40 px hoch (Tippfläche), volle Breite untereinander + const reach = await page.evaluate(() => { + const out = [] + for (const el of document.querySelectorAll('.wk-pult-actions > *')) { + el.scrollIntoView({ block: 'center' }) + const r = el.getBoundingClientRect() + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) + out.push({ text: el.textContent.trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) }) + } + return out + }) + const bad = reach.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40) + check('handy-freigabe: alle Aktionsknöpfe und Links sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach.length >= 10 && bad.length === 0, { count: reach.length, bad }) + const w1 = await width() + check('handy-freigabe: nach dem Durchblättern der Aktionen weiterhin kein seitliches Scrollen', w1.scroll <= w1.inner, w1) + + // Die lange Adresse steckt in der Originalanfrage: aufgeklappt darf sie die Seite nicht verbreitern + await rowOf(page, T_MAIL).locator('summary', { hasText: 'Originalanfrage' }).click() + await sleep(150) + const w1b = await width() + check('handy-freigabe: aufgeklappte „Originalanfrage“ mit langer Adresse verbreitert die Seite nicht', w1b.scroll <= w1b.inner, w1b) + await rowOf(page, T_MAIL).scrollIntoViewIfNeeded() + await shoot(page, 'handy-freigabe-originalanfrage.png') + + // Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar + const opener = rowOf(page, T_MAIL).getByRole('button', { name: 'Details', exact: true }) + await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) + await opener.scrollIntoViewIfNeeded() + await opener.click() + const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true }) + await dialog.waitFor() + await dialog.locator('.wk-prospect').waitFor() + await sleep(700) + const box = await dialog.evaluate((el) => { + const r = el.getBoundingClientRect() + const body = el.querySelector('.dialog-body') + return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, bodyOverflowX: body.scrollWidth - body.clientWidth, bodyScrolls: body.scrollHeight > body.clientHeight } + }) + metrics.phonePanel = box + check('handy-freigabe: Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box) + check('handy-freigabe: Panel ohne waagerechten Überlauf (lange Adressen brechen um)', box.bodyOverflowX <= 0, box) + const w2 = await width() + check('handy-freigabe: mit offenem Panel kein seitliches Scrollen', w2.scroll <= w2.inner, w2) + await shoot(page, 'handy-interessent-panel.png') + // ganz nach unten im Panel: Nachrichtenverlauf und Schließen-Knopf + await dialog.locator('.dialog-body').evaluate((el) => { el.scrollTop = el.scrollHeight }) + await sleep(200) + await shoot(page, 'handy-interessent-panel-unten.png') + const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last() + const closeReach = await closeBtn.evaluate((el) => { + const r = el.getBoundingClientRect() + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) + return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) } + }) + check('handy-freigabe: „Schließen“ im Panel ist erreichbar (im Fenster, nicht verdeckt)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.top >= 0 && closeReach.bottom <= closeReach.inner && !closeReach.covered, closeReach) + const lastMsg = await dialog.locator('li.wk-prospect-msg').last().evaluate((el) => { + const r = el.getBoundingClientRect() + const foot = el.closest('.dialog').querySelector('.dialog-foot').getBoundingClientRect() + return { bottom: Math.round(r.bottom), footTop: Math.round(foot.top) } + }) + check('handy-freigabe: nach dem Blättern liegt die letzte Nachricht über dem Fuß (nichts abgeschnitten)', lastMsg.bottom <= lastMsg.footTop + 1, lastMsg) + await closeBtn.click() + await page.getByRole('dialog').waitFor({ state: 'detached' }) + check('handy-freigabe: „Schließen“ schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) + await context.close() +} + + +const SCENARIOS = [ + ['desktop', desktop], ['blasen', bubbles], ['handy', phone], ['reduziert', reduced], ['notbremse', halted], ['ausfall', outage], + ['freigabe', freigabe], ['interessent', interessent], ['anhalten', anhalten], ['handy-freigabe', handyFreigabe], +]; + (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) - await outage(browser, base) - } catch (e) { - problems.push('Abbruch: ' + (e && e.stack ? e.stack : String(e))) + const only = (process.env.HARNESS_ONLY || '').split(',').map((name) => name.trim()).filter(Boolean) + for (const [name, run] of SCENARIOS) { + if (only.length > 0 && !only.includes(name)) continue + try { + await run(browser, base) + } catch (e) { + problems.push('Abbruch in Szenario ' + name + ': ' + (e && e.stack ? e.stack : String(e))) + } + } } finally { await browser.close() server.close() diff --git a/scripts/workers-harness/stub-auth.js b/scripts/workers-harness/stub-auth.js new file mode 100644 index 0000000..918b5bf --- /dev/null +++ b/scripts/workers-harness/stub-auth.js @@ -0,0 +1,7 @@ +// Ersatz für src/context/AuthContext.jsx im Prüfstand: ein angemeldeter Admin (Kenso, Kennung u-me), ohne Appwrite. +// Die Kennung passt zu `mensch:u-me` in den Pult-Einträgen der Skript-API ("von mir übernommen"). +const auth = Object.freeze({ isAdmin: true, user: Object.freeze({ $id: 'u-me', name: 'Kenso' }) }) + +export function useAuth() { + return auth +} diff --git a/scripts/workers-harness/stub-client.js b/scripts/workers-harness/stub-client.js new file mode 100644 index 0000000..b90123a --- /dev/null +++ b/scripts/workers-harness/stub-client.js @@ -0,0 +1,10 @@ +// Ersatz für src/lib/workersClient.js im Prüfstand (build.mjs tauscht das Modul aus): die Seite WorkersPage bekommt ihre API von hier, +// ohne Appwrite-Sitzung. `workersApi` ist ein Stellvertreter mit fester Identität (die Hooks hängen an `api`), der jeden Aufruf an die +// Skript-API weiterreicht, die entry.jsx in harness-bridge.js ablegt. +import { holder } from './harness-bridge.js' + +export const workersApi = new Proxy({}, { + get: (_target, name) => (typeof name === 'string' ? (...args) => holder.api[name](...args) : undefined), +}) + +export function resetWorkersJwt() {} diff --git a/scripts/workers-harness/stub-employees.js b/scripts/workers-harness/stub-employees.js new file mode 100644 index 0000000..79c60cf --- /dev/null +++ b/scripts/workers-harness/stub-employees.js @@ -0,0 +1,19 @@ +// Ersatz für src/hooks/useEmployees.js im Prüfstand: die Mitarbeiterliste ({ userId, displayName }) kommt nach kurzer Ladezeit +// (wie im Betrieb: erst leer, dann gefüllt), ohne Appwrite. Die Namen sind die, die der Prüfstand in den Zeilen erwartet. +import { useEffect, useState } from 'react' + +export const EMPLOYEES = Object.freeze([ + Object.freeze({ userId: 'u-me', displayName: 'Kenso' }), + Object.freeze({ userId: 'u-kollege', displayName: 'Justin' }), +]) + +const LOAD_MS = 60 + +export function useEmployees() { + const [state, setState] = useState({ employees: [], loading: true }) + useEffect(() => { + const timer = setTimeout(() => setState({ employees: [...EMPLOYEES], loading: false }), LOAD_MS) + return () => clearTimeout(timer) + }, []) + return { ...state, error: null, refresh: async () => {} } +}