Workers P2: Prüfstand um Ausfall-Szenario erweitert (503, 401-Folge, hängende Verbindung, versteckter Tab)
Endprüfung P2. Die Zustände des Boards bei Ausfall des Dienstes und der Wechsel zwischen sichtbarem und verstecktem Tab waren nur über Quelltext-Muster geprüft. Der Prüfstand (?fault=none|down|session|hang) schaltet jetzt den echten API-Client hinter eine Fetch-Weiche (scripted-fetch.js, mit Node-Tests). Szenario F in shot.cjs prüft im Browser: Hinweis mit „Neu laden“ bei 503 beim Öffnen und Rückkehr, Warnzeile „Verbindung gestört“ im Betrieb, versteckter Tab (keine Abrufe, keine Bilder, beim Zurückkehren sofort beides), dauerhaftes 401 (höchstens ein frisches JWT, Meldung „Anmeldung abgelaufen“, Erholung ohne neues Anmelden) und eine Antwort, die nie kommt (Zeitgrenze). Das Handy-Szenario prüft zusätzlich Auswahl aus der Liste (Karte im Fenster, Fokus) und Fokus nach „Karte schließen“. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84fb37a98e
commit
aceb558564
4 files changed
+230
-3
No files matched your search
@@ -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(
|
||||
<StrictMode>
|
||||
@@ -24,7 +42,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<main id="inhalt">
|
||||
<div className="page wk-page">
|
||||
<PageHeader title="Hustler" subtitle="Die Nachtschicht deiner Claude-Armee" />
|
||||
<WorkersBoard api={scripted} isAdmin stageRef={stage} />
|
||||
<WorkersBoard api={api} isAdmin stageRef={stage} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <distDir> <outDir>
|
||||
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
|
||||
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in new issue
Block a user