Workers P3: Prüfstand um Freigabe, Interessenten-Panel, Anhalten-Rückfrage und Handy erweitert

Echte Seite WorkersPage im Prüfstand (Austausch-Module für Anmeldung, Mitarbeiterliste und Sitzungs-JWT), Skript-API mit Pult, Aktionen und Interessent samt Schaltern für 409, 400, Ausfall und Leerzustand, vier neue Szenarien (Freigabe, Interessent, Anhalten, Handy), jedes Szenario läuft für sich, und der Szenario Desktop bestätigt jetzt die Rückfrage vor dem Anhalten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 10:20:29 +00:00
1 parent 686db61e1c
commit 2ed2201b22
9 files changed
+847 -20

No files matched your search

+506 -8
View File
@@ -4,6 +4,10 @@
// 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.
// 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 <body> (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()