// Playwright-Prüfung der Kundenseiten zu Aufträgen (Strang L6): auftrag.html, Abschnitt „Aufträge“ im Dashboard, widerrufen.html. // Statischer Server für public/ + page.route-Nachbau der /api-Antworten; nichts verlässt den Rechner. Nicht Teil von npm test. // Aufruf: free -m; flock /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-schwer.lock nice node test/ui/auftraege-kundenportal.ui.mjs // Bildschirmfotos: $SHOTS (Standard: Scratchpad auftraege-l6/). Exit-Code 1, wenn eine Prüfung scheitert. import http from 'node:http' import { readFile, mkdir } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' import { TOKEN, ab, privat, bestaetigt, eintrag } from '../auftraegeKundenportalDaten.js' const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') const SHOTS = process.env.SHOTS || '/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/auftraege-l6' await mkdir(SHOTS, { recursive: true }) const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2' } const server = http.createServer(async (req, res) => { try { const datei = path.join(pub, new URL(req.url, 'http://x').pathname) if (!datei.startsWith(pub + path.sep)) throw new Error('außerhalb von public/') res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' }) res.end(await readFile(datei)) } catch { res.writeHead(404) res.end('nicht gefunden') } }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const base = `http://127.0.0.1:${server.address().port}` let failed = 0 const check = (name, ok, detail = '') => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) if (!ok) failed += 1 } const norm = (text) => String(text).replace(/\s+/g, ' ').trim() // auch das geschützte Leerzeichen vor „€“ const laufe = async (name, fn) => { try { await fn() } catch (err) { check(name, false, String(err.message).split('\n')[0]) } } let aufrufe = [] // alle /api-Anfragen der laufenden Prüfung async function mocke(page, handler) { aufrufe = [] await page.unroute('**/api/**') await page.route('**/api/**', async (route) => { const req = route.request() const roh = req.postData() aufrufe.push({ pfad: new URL(req.url()).pathname, methode: req.method(), body: roh ? JSON.parse(roh) : undefined }) const antwort = await handler(aufrufe.at(-1)) if (antwort === 'abbrechen') return route.abort() const { status = 200, body = {} } = antwort || {} return route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) }) } const GET_AB = `/api/auftrag/${TOKEN}` const POST_AB = `/api/auftrag/${TOKEN}/bestaetigen` const KUNDE = { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } const RECHNUNGEN = [ { id: 'in_1', source: 'stripe', number: 'WEBKLAR-0009', date: '2026-10-06', dueDate: '2026-10-20', amount: 500, balance: 500, status: 'Offen', paid: false, cancelled: false, overdue: false, portalUrl: 'https://invoice.stripe.test/i/in_1', hasPdf: true, projekt: 'Website Anelia Wolf' }, { id: 'in_2', source: 'stripe', number: 'H96T-0004', date: '2026-10-01', dueDate: '', amount: 25, balance: 0, status: 'Bezahlt', paid: true, cancelled: false, overdue: false, portalUrl: '', hasPdf: true, projekt: '' }, ] const AUFTRAEGE = [ eintrag({ id: 'ab3', nummer: 'AB-2026-0003' }), eintrag({ id: 'ab2', ...bestaetigt(privat()), nummer: 'AB-2026-0002', titel: 'Logo und Visitenkarten', kannBestaetigen: false }), eintrag({ id: 'ab1', nummer: 'AB-2026-0001', titel: 'Wartung Oktober', status: 'storniert', kannBestaetigen: false, art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten.' }), ] const dashboardApi = (me) => ({ pfad, methode }) => { if (pfad === '/api/auth/me') return { body: me } if (pfad === '/api/invoices') return { body: { invoices: RECHNUNGEN, incomplete: false } } // wie L5: in der Kundenansicht ist kannBestaetigen immer false if (pfad === '/api/auftraege') return { body: { aktiv: true, auftraege: AUFTRAEGE.map((a) => (me.viewAs ? { ...a, kannBestaetigen: false } : a)) } } if (methode === 'POST' && pfad === '/api/auftraege/ab3/bestaetigen') return { body: { auftrag: bestaetigt(ab({ nummer: 'AB-2026-0003' })) } } if (pfad === '/api/projects') return { body: { projects: [] } } if (pfad === '/api/features') return { body: { features: [] } } if (pfad === '/api/activity') return { body: { tickets: [] } } if (pfad === '/api/contact') return { body: { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' } } if (pfad === '/api/chat/unread-count') return { body: { unread: 0 } } if (pfad === '/api/chat/messages') return { body: { messages: [], unread: 0 } } return { body: {} } } const postBody = () => JSON.stringify(aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body) const browser = await chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' })) try { for (const [label, viewport, mobil] of [['handy', { width: 390, height: 844 }, true], ['desktop', { width: 1440, height: 900 }, false]]) { const ctx = await browser.newContext({ viewport, deviceScaleFactor: mobil ? 2 : 1, isMobile: mobil, hasTouch: mobil, locale: 'de-DE', timezoneId: 'Europe/Berlin' }) const page = await ctx.newPage() page.setDefaultTimeout(30000) const jsFehler = [] page.on('pageerror', (e) => jsFehler.push(e.message)) const foto = async (name) => { // Einblend-Animationen des Dashboards abwarten; das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile: // darum das Fenster kurz auf die Seitenhöhe ziehen (höchstens 3200 px) await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {})))) const fenster = page.viewportSize() const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200)) await page.setViewportSize({ width: fenster.width, height: hoehe }) await page.screenshot({ path: `${SHOTS}/${name}-${label}.png`, fullPage: true }) await page.setViewportSize(fenster) } const zustand = (z) => page.waitForSelector(`#ab-wurzel[data-zustand="${z}"]`) const ohneUeberlauf = (selektor) => page.evaluate((s) => { const el = document.querySelector(s); return el.scrollWidth <= el.clientWidth + 1 }, selektor) const oeffne = async (pfad, handler) => { await mocke(page, handler) await page.goto('about:blank') // frisch laden, auch wenn sich nur das Fragment unterscheidet await page.goto(`${base}${pfad}`) } const oeffneAuftrag = (handler, adresse = `#t=${TOKEN}`) => oeffne(`/auftrag.html${adresse}`, handler) // K31 verlinkt #t= const alsAb = (a, neu) => ({ pfad, methode }) => { if (methode === 'GET' && pfad === GET_AB) return { body: { auftrag: a } } if (methode === 'POST' && pfad === POST_AB) return { body: { auftrag: neu } } } await laufe(`${label} unternehmen`, async () => { await oeffneAuftrag(alsAb(ab(), bestaetigt(ab()))) await zustand('offen') check(`${label} offen_titel`, norm(await page.textContent('h1')) === 'Auftragsbestätigung AB-2026-0002') check(`${label} formular_sie_form`, norm(await page.textContent('legend')) === 'Ihre Bestätigung') check(`${label} paragraf_19_aus_der_api`, norm(await page.textContent('[data-hinweis]')) === 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.') check(`${label} offen_positionen_und_summe`, (await page.locator('[data-position]').count()) === 2 && norm(await page.innerText('[data-gesamt]')) === 'Gesamtpreis 800,00 €') check(`${label} offen_links`, (await page.getAttribute('[data-link="pdf"]', 'href')) === `/api/auftrag/${TOKEN}/pdf` && (await page.getAttribute('[data-link="agb"]', 'href')) === '/rechtliches/agb-2026-09-30.pdf') check(`${label} offen_kein_zweites_haekchen`, (await page.locator('[data-feld="fruehbeginn"]').count()) === 0) check(`${label} fusszeile`, (await page.locator('footer a').allInnerTexts()).join('|') === 'Datenschutz|Impressum') check(`${label} kein_seitlicher_ueberlauf`, await ohneUeberlauf('html')) const knopf = page.locator('[data-aktion="bestaetigen"]') check(`${label} knopf_gesperrt_ohne_haken_und_gross_genug`, (await knopf.isDisabled()) && (await knopf.boundingBox()).height >= 44) await foto('auftrag-offen-unternehmen') await page.check('[data-feld="bestaetigung"]') check(`${label} knopf_frei_mit_haken`, await knopf.isEnabled()) await knopf.click() await zustand('bestaetigt') check(`${label} post_koerper`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false })) check(`${label} bestaetigt_zeit`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr') check(`${label} token_nicht_im_text`, !(await page.innerText('body')).includes(TOKEN)) }) await laufe(`${label} rueckfall`, async () => { await oeffneAuftrag(alsAb(ab()), `?t=${TOKEN}`) await zustand('offen') check(`${label} rueckfall_t_in_der_suche`, aufrufe.some((a) => a.methode === 'GET' && a.pfad === GET_AB)) }) await laufe(`${label} privat`, async () => { await oeffneAuftrag(alsAb(privat(), bestaetigt(privat()))) await zustand('offen') const frueh = page.locator('[data-feld="fruehbeginn"]') check(`${label} privat_zweites_haekchen_frei_waehlbar`, (await frueh.isVisible()) && !(await frueh.isChecked())) check(`${label} privat_kein_seitlicher_ueberlauf`, await ohneUeberlauf('html')) await foto('auftrag-offen-privat') await page.check('[data-feld="bestaetigung"]') await page.click('[data-aktion="bestaetigen"]') await zustand('bestaetigt') check(`${label} privat_ohne_fruehbeginn`, postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false })) check(`${label} privat_fruehester_beginn`, norm(await page.innerText('[data-bestaetigt]')) === 'Bestätigt am 07.10.2026, 10:15 Uhr Wir beginnen frühestens am 21.10.2026.') await foto('auftrag-bestaetigt-privat') }) await laufe(`${label} aufwand`, async () => { await oeffneAuftrag(alsAb(ab({ art: 'aufwand', positionen: [], summeCents: null, aufwandText: '100,00 € pro Stunde in Schritten von 15 Minuten. Geschätzter Umfang: etwa 10 Stunden (unverbindlich).', zahlungsplanText: 'Monatlich nach Aufwand.' }))) await zustand('offen') check(`${label} aufwand_text_statt_tabelle`, (await page.locator('[data-aufwand]').count()) === 1 && (await page.locator('table').count()) === 0) await foto('auftrag-aufwand') }) await laufe(`${label} fehlerzustaende`, async () => { await oeffneAuftrag(() => ({ status: 404, body: { error: 'Dieser Link ist ungültig oder abgelaufen.' } })) await zustand('ungueltig') check(`${label} ungueltig_kontaktweg`, (await page.locator('a[href="mailto:support@webklar.com"]').count()) === 1) await foto('auftrag-ungueltig') await oeffneAuftrag(() => ({ status: 410, body: { error: 'Diese Auftragsbestätigung wurde ersetzt.' } })) await zustand('beendet') check(`${label} beendet_titel`, norm(await page.textContent('h1')) === 'Diese Auftragsbestätigung wurde ersetzt.') await foto('auftrag-beendet') await oeffneAuftrag(() => ({ status: 429, body: {} })) await zustand('limit') let versuche = 0 await oeffneAuftrag(({ pfad }) => { if (pfad !== GET_AB) return undefined versuche += 1 return versuche === 1 ? { status: 500, body: { error: 'Interner Fehler.' } } : { body: { auftrag: ab() } } }) await zustand('fehler') await foto('auftrag-fehler') await page.click('[data-aktion="erneut"]') await zustand('offen') await oeffneAuftrag(() => 'abbrechen') await zustand('fehler') check(`${label} netzfehler_ohne_browsertext`, !/Failed to fetch|NetworkError|fetch failed/i.test(await page.innerText('body'))) await oeffne('/auftrag.html', () => ({})) await zustand('ungueltig') check(`${label} ohne_token_keine_anfrage`, aufrufe.length === 0) }) await laufe(`${label} portal`, async () => { await oeffne('/dashboard.html#rechnungen', dashboardApi({ authenticated: true, customer: KUNDE })) await page.waitForSelector('#orders-section:not(.hidden) .ab-karte') check(`${label} portal_reihenfolge`, JSON.stringify(await page.$$eval('.ab-karte', (k) => k.map((x) => x.getAttribute('data-auftrag')))) === JSON.stringify(['ab3', 'ab2', 'ab1'])) check(`${label} portal_du_form`, norm(await page.textContent('#orders-section > p')) === 'Deine Auftragsbestätigungen. Offene Aufträge bestätigst du hier mit einem Klick.' && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] legend')) === 'Deine Bestätigung') check(`${label} portal_projekt_unter_rechnungsnummer`, norm(await page.textContent('.inv-no .inv-project')) === 'Website Anelia Wolf') check(`${label} portal_offene_ab_aufgeklappt`, (await page.locator('.ab-karte[data-auftrag="ab3"] details').getAttribute('open')) !== null) check(`${label} portal_kein_seitlicher_ueberlauf`, await ohneUeberlauf('#orders-section')) await foto('portal-auftraege') await page.check('.ab-karte[data-auftrag="ab3"] [data-feld="bestaetigung"]') await page.click('.ab-karte[data-auftrag="ab3"] [data-aktion="bestaetigen"]') await page.waitForSelector('.ab-karte[data-auftrag="ab3"] [data-bestaetigt]') check(`${label} portal_post_und_status`, aufrufe.some((a) => a.methode === 'POST' && a.pfad === '/api/auftraege/ab3/bestaetigen') && postBody() === JSON.stringify({ bestaetigt: true, fruehbeginn: false }) && norm(await page.textContent('.ab-karte[data-auftrag="ab3"] .ab-pille')) === 'Bestätigt') }) await laufe(`${label} ansichtsmodus`, async () => { const me = { authenticated: true, role: 'admin', viewAs: true, viewId: 'a'.repeat(32), viewExpiresAt: Date.now() + 3_600_000, admin: { name: 'Andrej', email: 'andrej@webklar.com', isAdmin: false }, customer: KUNDE } await oeffne('/dashboard.html#rechnungen', dashboardApi(me)) await page.waitForSelector('#orders-section:not(.hidden) .ab-karte') const karte = '.ab-karte[data-auftrag="ab3"]' check(`${label} ansicht_gesperrt`, (await page.locator(`${karte} [data-aktion="bestaetigen"]`).isDisabled()) && (await page.locator(`${karte} [data-feld="bestaetigung"]`).isDisabled())) check(`${label} ansicht_hinweis`, norm(await page.textContent(`${karte} [data-gesperrt]`)) === 'Im Ansichtsmodus kann der Auftrag nicht bestätigt werden. Die Bestätigung gibt der Kunde selbst ab.') await foto('portal-auftraege-ansichtsmodus') }) await laufe(`${label} widerruf`, async () => { const api = ({ pfad, methode }) => { if (pfad === '/api/auth/me') return { body: { authenticated: false } } if (methode === 'POST' && pfad === '/api/abo/public/withdraw') return { body: { receivedAt: '2026-10-07T08:15:00.000Z' } } } const oeffneWiderruf = async () => { await oeffne('/widerrufen.html', api) await page.waitForFunction(() => document.getElementById('ab-nummer-wrap')?.classList.contains('hidden')) } const absenden = async (nummer) => { await page.check('input[name="order"][value="projekt"]') await page.fill('#name', 'Berta Berg') await page.fill('#email', 'b@example.test') if (nummer !== undefined) await page.fill('#abNummer', nummer) await page.click('button[type="submit"]') } await oeffneWiderruf() check(`${label} widerruf_feld_zuerst_versteckt`, !(await page.locator('#ab-nummer-wrap').isVisible())) await absenden('AB-2026-01') check(`${label} widerruf_feld_nur_bei_projektauftrag`, await page.locator('#ab-nummer-wrap').isVisible()) check(`${label} widerruf_unpassende_nummer_gestoppt`, aufrufe.every((a) => a.methode !== 'POST') && norm(await page.textContent('#form-error')).startsWith('Die Nummer sieht anders aus')) await foto('widerrufen-projekt') await page.fill('#abNummer', 'ab 2026 0001') await page.click('button[type="submit"]') await page.waitForSelector('#done:not(.hidden)') const mitNummer = aufrufe.find((a) => a.methode === 'POST')?.body check(`${label} widerruf_nummer_vereinheitlicht`, mitNummer?.order === 'projekt' && mitNummer.abNummer === 'AB-2026-0001', JSON.stringify(mitNummer)) check(`${label} widerruf_bestaetigung_nennt_nummer`, norm(await page.textContent('#done-list')).includes('Nummer der AuftragsbestätigungAB-2026-0001')) await oeffneWiderruf() await absenden() // ohne Nummer: freiwillig await page.waitForSelector('#done:not(.hidden)') const ohneNummer = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body check(`${label} widerruf_ohne_nummer_geht_durch`, ohneNummer?.order === 'projekt' && ohneNummer.abNummer === '', JSON.stringify(ohneNummer)) await oeffneWiderruf() await page.fill('#name', 'Berta Berg') await page.fill('#email', 'b@example.test') await page.click('button[type="submit"]') await page.waitForSelector('#done:not(.hidden)') const andere = aufrufe.filter((a) => a.methode === 'POST').at(-1)?.body check(`${label} widerruf_andere_art_ohne_nummer`, andere?.order === 'abschluss' && andere.abNummer === '', JSON.stringify(andere)) }) check(`${label} keine_js_fehler`, jsFehler.length === 0, jsFehler.join(' | ')) await ctx.close() } // Dunkler Modus (Systemeinstellung): die Seite folgt den dunklen Farben der Mailvorlagen const dunkel = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'de-DE', colorScheme: 'dark' }) const seite = await dunkel.newPage() await laufe('dunkel', async () => { await mocke(seite, ({ pfad }) => (pfad === GET_AB ? { body: { auftrag: privat() } } : undefined)) await seite.goto(`${base}/auftrag.html#t=${TOKEN}`) await seite.waitForSelector('#ab-wurzel[data-zustand="offen"]') check('dunkel hintergrund', (await seite.evaluate(() => getComputedStyle(document.body).backgroundColor)) === 'rgb(22, 22, 21)') await seite.screenshot({ path: `${SHOTS}/auftrag-offen-privat-dunkel-handy.png`, fullPage: true }) }) await dunkel.close() } finally { await browser.close() server.close() } console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok') process.exit(failed ? 1 : 0)