diff --git a/scripts/workers-harness/entry.jsx b/scripts/workers-harness/entry.jsx index 680d841..35fb044 100644 --- a/scripts/workers-harness/entry.jsx +++ b/scripts/workers-harness/entry.jsx @@ -1,6 +1,9 @@ // Einstiegspunkt des Prüfstands: das Hochhaus-Board der Hustler-Seite mit der Skript-API, ohne Anmeldung und ohne Appwrite. // Drumherum steht dieselbe Hülle wie in der App (Menüleiste links, Seitenrand), damit die Bühne genauso breit wird wie im echten Betrieb. // ?halted=1 startet mit gesetzter Bounce-Notbremse (H4). window.__wk gibt es nur hier: die Bühne und die Skript-API für shot.cjs. +// ?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). import { StrictMode, createRef } from 'react' import { createRoot } from 'react-dom/client' import { MemoryRouter } from 'react-router-dom' @@ -8,12 +11,27 @@ 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 { createWorkersApi } from '../../src/lib/workersApi.js' import { createScriptedApi } from './scripted-api.js' +import { createScriptedFetch } from './scripted-fetch.js' const params = new URLSearchParams(window.location.search) const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1' }) const stage = createRef() -window.__wk = { stage, api: scripted } +const fault = params.get('fault') +const control = { fault: fault && fault !== 'none' ? fault : null, calls: [], jwts: [] } +// Zeitgrenze im Prüfstand kurz (1,5 s statt 20 s), damit der Ausfall „Antwort kommt nie“ in Sekunden sichtbar wird +const api = fault === null + ? scripted + : createWorkersApi({ + getJwt: async (options) => { + control.jwts.push(Boolean(options && options.fresh)) + return 'harness-jwt' + }, + fetchImpl: createScriptedFetch(scripted, control), + timeoutMs: 1500, + }) +window.__wk = { stage, api: scripted, control } createRoot(document.getElementById('root')).render( @@ -24,7 +42,7 @@ createRoot(document.getElementById('root')).render(
- +
diff --git a/scripts/workers-harness/scripted-fetch.js b/scripts/workers-harness/scripted-fetch.js new file mode 100644 index 0000000..345f578 --- /dev/null +++ b/scripts/workers-harness/scripted-fetch.js @@ -0,0 +1,42 @@ +// Fetch-Weiche des Prüfstands: nimmt die Anfragen des echten API-Clients (createWorkersApi, URLs unter /api/workers) entgegen und +// 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. +// control.calls sammelt jede Anfrage ({ method, path, jwt }), damit der Prüfstand zählen kann. + +const reply = (status, body) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => { + if (body === undefined) throw new SyntaxError('kein JSON') + return body + }, +}) + +export function createScriptedFetch(api, control) { + return async function scriptedFetch(url, init = {}) { + const method = init.method || 'GET' + const parsed = new URL(url, 'http://harness.invalid') + const path = parsed.pathname.replace(/^\/api\/workers/, '') + control.calls.push({ method, path, jwt: init.headers ? init.headers['X-Appwrite-JWT'] : undefined }) + if (control.fault === 'hang') return new Promise(() => {}) + if (control.fault === 'down') return reply(503, undefined) + if (control.fault === 'session') return reply(401, {}) + try { + const body = init.body === undefined ? undefined : JSON.parse(init.body) + if (method === 'GET' && path === '/state') return reply(200, await api.state()) + if (method === 'GET' && path === '/events') { + const limit = parsed.searchParams.get('limit') + return reply(200, await api.events(parsed.searchParams.get('after'), limit === null ? undefined : Number(limit))) + } + const trace = /^\/worker\/([^/]+)\/trace$/.exec(path) + if (method === 'GET' && trace) return reply(200, await api.trace(decodeURIComponent(trace[1]))) + 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)) + 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 bb534ef..9a11d10 100644 --- a/scripts/workers-harness/shot.cjs +++ b/scripts/workers-harness/shot.cjs @@ -1,6 +1,6 @@ // Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand. // Aufruf (immer unter Speicherdeckel, serielle Läufe): -// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 200 node --max-old-space-size=400 \ +// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \ // scripts/workers-harness/shot.cjs // = Ausgabe von build.mjs, = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo. // Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1. @@ -306,6 +306,23 @@ async function phone(browser, base) { check('handy: „+“ erhöht den Zoom', z1 > z0, [z0, z1]) await shoot(page, 'handy-zoom.png') await shoot(page, 'handy-ganz.png', { fullPage: true }) + + // Auswahl aus der Liste (die Liste ist auf dem Handy die Alternative zur Leinwand): die Karte kommt ins Bild und nimmt den Fokus, + // „Karte schließen“ gibt den Fokus an den Listeneintrag zurück (Endprüfung P2) + const bea = page.locator('[data-wk-worker="bea"]') + await bea.scrollIntoViewIfNeeded() + await bea.click() + await page.waitForFunction(() => document.activeElement && document.activeElement.classList.contains('wk-name') && document.activeElement.textContent === 'Bea') + check('handy: nach der Auswahl in der Liste hat der Name in der Karte den Fokus', true) + await sleep(300) + const cardBox = await page.locator('.wk-card').boundingBox() + const view = await page.evaluate(() => window.innerHeight) + metrics.phoneCardTop = Math.round(cardBox.y) + check('handy: nach der Auswahl in der Liste liegt der Kartenanfang im Fenster', cardBox.y >= 0 && cardBox.y <= view - 120, { top: cardBox.y, view }) + await shoot(page, 'handy-karte.png') + await page.locator('button[aria-label="Karte schließen"]').click() + const back = await page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-wk-worker') : null)) + check('handy: „Karte schließen“ gibt den Fokus an den Listeneintrag zurück', back === 'bea', back) await context.close() } @@ -357,6 +374,103 @@ async function halted(browser, base) { await context.close() } +// ---------- Szenario F: Ausfall, Wiederkehr, versteckter Tab, 401-Folge (Endprüfung P2) ---------- +// Das Board bekommt hier den echten API-Client (createWorkersApi) hinter einer Fetch-Weiche mit Ausfall-Schalter (entry.jsx, ?fault=). + +const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.' +const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.' +const callCount = (page) => page.evaluate(() => window.__wk.control.calls.length) +const freshMints = (page) => page.evaluate(() => window.__wk.control.jwts.filter(Boolean).length) +const setFault = (page, fault) => page.evaluate((f) => { window.__wk.control.fault = f }, fault) +// Tab verstecken oder zurückholen: Headless-Chromium kennt keinen versteckten Tab, darum werden die beiden Eigenschaften überschrieben +const setHidden = (page, hidden) => page.evaluate((h) => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => h }) + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (h ? 'hidden' : 'visible') }) + document.dispatchEvent(new Event('visibilitychange')) +}, hidden) + +async function outage(browser, base) { + const { context, page } = await newPage(browser, 'ausfall', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + + // 1. Dienst beim Öffnen nicht erreichbar (503 ohne JSON): Hinweis mit „Neu laden“ statt leerer Seite + await page.goto(base + '/index.html?fault=down') + const empty = page.locator('.empty') + await empty.waitFor() + const emptyText = await textOf(empty) + check('ausfall: 503 beim Öffnen: „Der Hustler-Dienst ist noch nicht erreichbar“ mit der Dienst-Meldung', emptyText.includes('Der Hustler-Dienst ist noch nicht erreichbar') && emptyText.includes(DOWN_TEXT), emptyText) + check('ausfall: im Ausfall kein Hochhaus', (await page.locator('.wk-canvas').count()) === 0) + await shoot(page, 'desktop-ausfall.png') + const asked = await callCount(page) + await page.getByRole('button', { name: 'Neu laden', exact: true }).click() + await page.waitForFunction((n) => window.__wk.control.calls.length > n, asked) + await sleep(300) + check('ausfall: „Neu laden“ fragt den Dienst erneut, der Hinweis bleibt', (await page.locator('.empty').count()) === 1) + + // 2. Dienst kehrt zurück: „Neu laden“ zeigt das Hochhaus + await setFault(page, null) + await page.getByRole('button', { name: 'Neu laden', exact: true }).click() + await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19 && window.__wk.stage.current.stats().frames > 0, null, { timeout: 8000 }) + check('ausfall: nach „Neu laden“ ist das Hochhaus mit 19 Figuren da', true) + check('ausfall: ohne Fehler keine Warnzeile', (await page.locator('.msg.warn').count()) === 0) + + // 3. Ausfall mitten im Betrieb: Warnzeile, die Daten bleiben stehen + await setFault(page, 'down') + const warn = page.locator('.msg.warn[role="status"]') + await warn.waitFor({ timeout: 9000 }) + const warnText = await textOf(warn) + check('ausfall: 503 im Betrieb: Warnzeile „Verbindung gestört: …“, das Hochhaus bleibt', warnText === 'Verbindung gestört: ' + DOWN_TEXT + ' Es wird weiter versucht.' && (await page.locator('[data-wk-worker]').count()) === 19, warnText) + await topOfPage(page) + await shoot(page, 'desktop-verbindung-gestoert.png') + + // 4. Versteckter Tab: keine Abrufe, keine Bilder, auch wenn der Dienst inzwischen wieder da ist; beim Zurückkehren sofort beides + await setHidden(page, true) + await setFault(page, null) + await sleep(400) + const hiddenCalls = await callCount(page) + const hiddenFrames = (await stats(page)).frames + await sleep(6500) // länger als der Takt der Ereignisse (5 s) + const stillCalls = await callCount(page) + const stillFrames = (await stats(page)).frames + metrics.hidden = { calls: [hiddenCalls, stillCalls], frames: [hiddenFrames, stillFrames] } + check('ausfall: versteckter Tab: keine Abrufe in 6,5 s', stillCalls === hiddenCalls, [hiddenCalls, stillCalls]) + check('ausfall: versteckter Tab: die Szene zeichnet nicht (höchstens 1 Bild Nachlauf)', stillFrames - hiddenFrames <= 1, [hiddenFrames, stillFrames]) + check('ausfall: versteckter Tab: die Warnzeile bleibt bis zur Rückkehr stehen', (await page.locator('.msg.warn').count()) === 1) + await setHidden(page, false) + await page.waitForFunction((n) => window.__wk.control.calls.length > n, stillCalls, { timeout: 1500 }) + check('ausfall: Rückkehr in den Tab fragt sofort ab (binnen 1,5 s, ohne den Takt abzuwarten)', true) + await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 3000 }) + check('ausfall: nach der Rückkehr verschwindet die Warnzeile', true) + const resumed = (await stats(page)).frames + await sleep(600) + check('ausfall: nach der Rückkehr zeichnet die Szene wieder', (await stats(page)).frames > resumed) + + // 5. Dauerhaftes 401 (Dienst lehnt das JWT ab): weiter abfragen, aber höchstens ein frisches JWT, Meldung „Anmeldung abgelaufen“ + await pickFromList(page, 'kim', 'Kim') // die Spur der Karte fragt ebenfalls ab + const before = await callCount(page) + const mintsBefore = await freshMints(page) + await setFault(page, 'session') + await page.waitForFunction((n) => window.__wk.control.calls.length >= n + 4, before, { timeout: 30000 }) + const mints = (await freshMints(page)) - mintsBefore + const sessionWarn = await textOf(page.locator('.msg.warn[role="status"]')) + metrics.session401 = { calls: (await callCount(page)) - before, freshMints: mints } + check('ausfall: dauerhaftes 401: höchstens ein frisches JWT trotz mehrerer Abrufe', mints <= 1, mints) + check('ausfall: dauerhaftes 401: Warnzeile nennt die abgelaufene Anmeldung', sessionWarn === 'Verbindung gestört: ' + SESSION_TEXT + ' Es wird weiter versucht.', sessionWarn) + await setFault(page, null) + await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 20000 }) // der Zustand wird nur alle 15 s geholt + check('ausfall: nimmt der Dienst das JWT wieder an, verschwindet die Warnzeile ohne neues Anmelden', true) + await context.close() + + // 6. Antwort kommt nie (tote Verbindung): nach der Zeitgrenze (im Prüfstand 1,5 s) erscheint der Hinweis, nicht ewiges „Wird geladen …“ + const hang = await newPage(browser, 'ausfall-haengt', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) + const t0 = Date.now() + await hang.page.goto(base + '/index.html?fault=hang') + await hang.page.locator('.loading').waitFor() + await hang.page.locator('.empty').waitFor({ timeout: 6000 }) + metrics.hangSeconds = Math.round((Date.now() - t0) / 100) / 10 + check('ausfall: hängende Verbindung endet nach der Zeitgrenze mit der Dienst-Meldung', (await textOf(hang.page.locator('.empty'))).includes(DOWN_TEXT), metrics.hangSeconds) + await hang.context.close() +} + (async () => { const server = await serve() const base = 'http://127.0.0.1:' + server.address().port @@ -367,6 +481,7 @@ async function halted(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))) } finally { diff --git a/tests/unit/workersHarness.test.mjs b/tests/unit/workersHarness.test.mjs index e3ce9ef..311af91 100644 --- a/tests/unit/workersHarness.test.mjs +++ b/tests/unit/workersHarness.test.mjs @@ -118,3 +118,55 @@ test('scripted api: Spur mit 12 Ereignissen (unsicherer und sicherer Link), Tage assert.deepEqual(await api.pult('offen'), { items: [] }) assert.deepEqual(await api.botleads({}), { items: [], total: 0 }) }) + +// ---- Endprüfung P2: Fetch-Weiche des Prüfstands (echter API-Client gegen die Skript-API, mit Ausfall-Schalter) ---- + +const { createScriptedFetch } = await loadModule('scripts/workers-harness/scripted-fetch.js') +const { createWorkersApi } = await loadModule('src/lib/workersApi.js') + +function wired(fault = null, extra = {}) { + const control = { fault, calls: [], jwts: [] } + const scripted = createScriptedApi({ now: () => 0 }) + const api = createWorkersApi({ + getJwt: async (o) => { + control.jwts.push(o?.fresh === true) + return 'harness-jwt' + }, + fetchImpl: createScriptedFetch(scripted, control), + ...extra, + }) + return { control, scripted, api } +} + +test('Fetch-Weiche gesund: der echte API-Client erreicht Zustand, Ereignisse, Spur, Pause und Einstellungen der Skript-API', async () => { + const { control, scripted, api } = wired() + assert.equal((await api.state()).workers.length, 19) + assert.deepEqual(await api.events(null, 5), { events: [] }) + assert.equal((await api.trace('kim')).worker.name, 'Kim') + assert.deepEqual(await api.pause(true), { paused: true }) + assert.equal((await api.state()).paused, true) + assert.equal((await api.settings()).paused, true) + await api.saveSettings({ sendingHalted: false }) + assert.deepEqual(scripted.saved, [{ sendingHalted: false }]) + await assert.rejects(api.trace('gibt-es-nicht'), { message: 'Diesen Hustler gibt es nicht.', status: 404 }) + assert.deepEqual(control.calls.map((c) => c.path).slice(0, 3), ['/state', '/events', '/worker/kim/trace']) + assert.ok(control.calls.every((c) => c.jwt === 'harness-jwt')) +}) + +test('Fetch-Weiche down: 503 ohne JSON ergibt die Dienst-Meldung, session: 401 ergibt die Anmelde-Meldung, danach nur ein createJWT je Sperre', async () => { + const down = wired('down') + await assert.rejects(down.api.state(), { message: 'Der Hustler-Dienst ist gerade nicht erreichbar.', status: 503 }) + const session = wired('session') + for (let i = 0; i < 6; i += 1) await assert.rejects(session.api.events(null), { message: 'Anmeldung abgelaufen. Bitte neu anmelden.', status: 401 }) + assert.equal(session.control.jwts.filter(Boolean).length, 1) + assert.equal(session.control.calls.length, 7) // 1 Versuch + 1 Wiederholung, dann je Aufruf eine Anfrage + // Schalter zurück: der Dienst nimmt das JWT wieder an, ohne dass etwas neu erzeugt wird + session.control.fault = null + assert.equal((await session.api.state()).workers.length, 19) + assert.equal(session.control.jwts.filter(Boolean).length, 1) +}) + +test('Fetch-Weiche hang: die Antwort kommt nie, der API-Client gibt nach der Zeitgrenze auf', async () => { + const { api } = wired('hang', { timeoutMs: 40 }) + await assert.rejects(api.state(), { message: 'Der Hustler-Dienst ist gerade nicht erreichbar.', status: 0 }) +})