// Playwright-Checks Funktionen-Tab und News-Editor: statischer Server fuer public/ + per page.route gemockte API. // Nicht Teil von npm test. Aufruf: node test/ui/funktionen.ui.mjs // Screenshots der Hauptansichten landen in /root/reports/funktionen-news-ui/. 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' const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') const SHOTS = '/root/reports/funktionen-news-ui' await mkdir(SHOTS, { recursive: true }) const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' } const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://x') try { const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname) const data = await readFile(file) res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }) res.end(data) } 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}` // ---- Fixtures ---- const feat = (state, extra = {}) => ({ state, siteKey: 'abcdefghijklmnop', ticketWoid: state === 'off' ? null : '1234', orderedAt: state === 'off' ? null : '2026-09-28T09:00:00.000Z', activatedAt: state === 'active' ? '2026-09-29T09:00:00.000Z' : null, invoiceUrl: state === 'off' ? null : 'https://example.test/rechnung', ...extra }) const proj = (id, name, state) => ({ projectId: id, projectName: name, liveUrl: 'https://baeckerei.example', features: { news: feat(state) } }) const SCENARIOS = { off: [proj('p1', 'Bäckerei Berg', 'off')], setup: [proj('p1', 'Bäckerei Berg', 'setup')], active: [proj('p1', 'Bäckerei Berg', 'active')], disabled: [proj('p1', 'Bäckerei Berg', 'disabled')], multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')], } const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.' const post = (id, type, title, text, paper, extra = {}) => ({ $id: id, type, title, text, paper, duration: '1m', imageId: type === 'note' ? null : `img-${id}`, visibleFrom: '2026-09-25T10:00:00.000Z', visibleUntil: '2026-10-25T21:59:59.999Z', takenDownAt: null, ...extra }) const newsList = { hanging: [post('n1', 'combo', 'Danke für eure Spenden!', 'Diese Woche sind drei volle Kisten zusammengekommen.', 'sun'), post('n2', 'note', 'Freitag geschlossen', 'Am 3. Oktober bleibt der Laden zu.', 'sky')], expired: [post('n3', 'photo', 'Holzspielzeug', '', 'white', { visibleUntil: '2026-09-20T21:59:59.999Z' })], liveUrl: 'https://baeckerei.example', } const tickets = [1, 2, 3, 4].map((n) => ({ number: String(1200 + n), topic: `Auftrag ${n}`, createdAt: `2026-09-${10 + n}T10:00:00.000Z`, status: { step: 4, tone: 'done', label: 'Erledigt' }, entries: [{ kind: 'update', label: 'Änderung', date: '2026-09-20', person: 'Kenso', text: 'Text angepasst' }], })) let scenario = 'off' const checkoutCalls = [] async function mockApi(page) { await page.route('**/api/**', async (route) => { const req = route.request() const url = new URL(req.url()) const p = url.pathname const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } }) if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] }) if (p === '/api/features') return json({ features: [] }) if (p === '/api/activity') return json({ tickets }) if (p === '/api/funktionen') return json({ projects: SCENARIOS[scenario], setupEstimate: 'meist innerhalb von 1–2 Wochen', consentText: CONSENT }) if (p === '/api/funktionen/news/checkout') { checkoutCalls.push(req.postDataJSON()) return json({ url: `${base}/checkout-fertig.html` }) } if (p === '/api/news' && req.method() === 'GET') return json(newsList) if (p.startsWith('/api/news/image/')) return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' }) if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' }) if (p === '/api/chat/unread-count') return json({ unread: 0 }) return json({}) }) } let failed = 0 const check = (name, ok, detail = '') => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) if (!ok) failed += 1 } const hScroll = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) const browser = await chromium.launch() try { for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) { const phone = label === 'phone' checkoutCalls.length = 0 const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 }) const page = await ctx.newPage() const errors = [] page.on('pageerror', (e) => errors.push(e.message)) await mockApi(page) const open = async (hash, sc = scenario) => { scenario = sc await page.goto('about:blank') // sonst laedt ein reiner Hash-Wechsel nicht neu await page.goto(`${base}/dashboard.html${hash}`) await page.waitForFunction(() => document.getElementById('customer-name')?.textContent) } // Einblend-Animation abwarten; Fenster auf Seitenhoehe ziehen, damit feste Leisten (Tab-Leiste) am Rand bleiben const shot = async (name) => { await page.waitForTimeout(900) const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 2600)) await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) }) await page.waitForTimeout(150) await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` }) await page.setViewportSize(viewport) } const noScroll = async (name) => check(`${label} kein_horizontaler_scroll ${name}`, await hScroll(page)) // Projekte + Fortschritt-Sektion await open('') await page.waitForSelector('#pj-activity .pd-order-card') const labels = await page.$$eval('.kb-nav-item .kb-tab-label', (els) => els.map((e) => e.textContent.trim())) check(`${label} nav_ohne_fortschritt`, labels.join(', ') === 'Projekte, Funktionen, Rechnungen, Chat, Verträge', labels.join(', ')) const shown = await page.$$eval('#pj-activity .pd-order-card', (els) => els.length) const more = await page.$('#pj-activity [data-activity-all]') check(`${label} projekte_drei_neueste`, shown === 3 && Boolean(more), `Karten ${shown}`) await noScroll('projekte') await shot('1-projekte') await open('#fortschritt') await page.waitForSelector('#pj-activity .pd-order-card') await page.waitForTimeout(900) const fort = await page.evaluate(() => { const r = document.getElementById('pj-activity').getBoundingClientRect() return { inView: r.top < window.innerHeight && r.bottom > 0, projectsActive: document.getElementById('view-projects').classList.contains('active'), navActive: document.querySelector('.kb-nav-item.active')?.dataset.view, } }) check(`${label} fortschritt_link`, fort.inView && fort.projectsActive && fort.navActive === 'projects', JSON.stringify(fort)) // Funktionen-Kacheln const tiles = {} for (const [sc, pill, btn, shotName] of [['off', 'Aus', 'Einrichten lassen · 30 €', '2-funktionen-aus'], ['setup', 'In Einrichtung', 'Status ansehen', '3-funktionen-einrichtung'], ['active', 'Aktiv', 'Beiträge schreiben', '4-funktionen-aktiv']]) { await open('#funktionen', sc) await page.waitForSelector('[data-fn-tile="news"] .fn-pill') const got = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim(), soon: [...document.querySelectorAll('.fn-tile:not([data-fn-tile="news"]) .fn-pill')].map((e) => e.textContent.trim()), })) tiles[sc] = got check(`${label} kacheln ${sc}`, got.pill === pill && got.btn === btn && got.soon.length === 2 && got.soon.every((t) => t === 'Kommt bald'), JSON.stringify(got)) await noScroll(`funktionen-${sc}`) await shot(shotName) } await open('#funktionen', 'multi') await page.waitForSelector('[data-fn-tile="news"] .fn-pill') const multi = await page.textContent('[data-fn-tile="news"]') check(`${label} kachel_mehrere_projekte`, multi.includes('aktiv für 1 von 2 Websites')) // Buchung await open('#funktionen', 'off') await page.click('[data-fn-tile="news"] .fn-tile-cta') await page.waitForSelector('#news-order') const bookText = await page.textContent('#view-news') check(`${label} buchung_texte`, bookText.includes('meist innerhalb von 1–2 Wochen') && bookText.includes(CONSENT) && bookText.includes('Leistungsbeschreibung')) check(`${label} bestellen_braucht_haken`, await page.isDisabled('#news-order') && (await page.textContent('#news-order')).trim() === 'Zahlungspflichtig bestellen · 30 €') await noScroll('news-buchung') await shot('5-news-buchung') await page.check('#news-consent') check(`${label} bestellen_haken_aktiv`, !(await page.isDisabled('#news-order'))) await page.click('#news-order') await page.waitForURL('**/checkout-fertig.html') check(`${label} bestellen_checkout`, checkoutCalls.length === 1 && checkoutCalls[0].projectId === 'p1' && checkoutCalls[0].consent === true, JSON.stringify(checkoutCalls)) // Rueckkehr von Stripe await open('#news?bestellt=1', 'setup') await page.waitForSelector('#view-news .fn-status') const back = await page.textContent('#view-news') check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen')) check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active'))) check(`${label} bestellt_flag_aus_hash`, await page.evaluate(() => !window.location.hash.includes('bestellt')), await page.evaluate(() => window.location.hash)) // Flag ohne bestellte Funktion (Zustand off): kein Dankeshinweis await open('#news?bestellt=1', 'off') await page.waitForSelector('#news-order') check(`${label} bestellt_flag_ignoriert_bei_off`, !(await page.textContent('#view-news')).includes('Danke!')) // Abgeschaltet: Pille und Knopf der Kachel, Status statt Buchung await open('#funktionen', 'disabled') await page.waitForSelector('[data-fn-tile="news"] .fn-pill') const dis = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim() })) check(`${label} kachel_abgeschaltet`, dis.pill === 'Abgeschaltet' && dis.btn === 'Status ansehen', JSON.stringify(dis)) check(`${label} news_ohne_aria_live`, await page.evaluate(() => !document.getElementById('view-news').hasAttribute('aria-live'))) // Editor await open('#news', 'active') await page.waitForSelector('#nw-title') check(`${label} editor_dauer_vorauswahl`, (await page.inputValue('#nw-duration')) === '1m') await page.fill('#nw-title', 'Neue Winterjacken') await page.waitForTimeout(50) const prev = await page.textContent('#nw-preview') check(`${label} editor_vorschau_live`, prev.includes('Neue Winterjacken')) await page.click('[data-type="note"]') check(`${label} editor_text_ohne_foto`, !(await page.isVisible('#nw-photo-field'))) await page.click('[data-type="combo"]') check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field')) const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`)) check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; ')) // Zurueck zum Tab und wieder in News (gleiches Projekt, gleicher Zustand): Entwurf bleibt stehen await page.fill('#nw-title', 'Entwurf bleibt') await page.evaluate(() => document.querySelector('.kb-nav-item[data-view="funktionen"]').click()) await page.waitForSelector('[data-fn-tile="news"] .fn-tile-cta') await page.click('[data-fn-tile="news"] .fn-tile-cta') await page.waitForSelector('#nw-title') await page.waitForTimeout(200) check(`${label} editor_entwurf_bleibt`, (await page.inputValue('#nw-title')) === 'Entwurf bleibt') await page.fill('#nw-title', 'Neue Winterjacken') const lists = await page.textContent('#view-news') check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen')) await page.click('[data-del]') check(`${label} editor_loeschen_rueckfrage`, await page.isVisible('.nw-confirm')) await page.click('.nw-confirm [data-cancel]') await noScroll('editor') await shot('6-news-editor') if (phone) { await open('#funktionen', 'active') await page.click('[data-fn-tile="news"] .fn-tile-cta') await page.waitForSelector('#nw-title') const back = await page.evaluate(() => { const b = document.getElementById('kb-back') return { hidden: b.classList.contains('hidden'), label: b.getAttribute('aria-label'), tab: document.querySelector('.kb-nav-item.active')?.dataset.view } }) check('phone editor_handy_unterseite', !back.hidden && back.label === 'Zurück zu Funktionen' && back.tab === 'funktionen', JSON.stringify(back)) } check(`${label} keine_seitenfehler`, errors.length === 0, errors.join(' | ')) await ctx.close() } } finally { await browser.close() server.close() } console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'Alle Checks bestanden') process.exit(failed ? 1 : 0)