Files
Webklar-Kundenbereich/test/ui/funktionen.ui.mjs
T

239 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#e8b96a"/></svg>' })
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)