239 lines
15 KiB
JavaScript
239 lines
15 KiB
JavaScript
// 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)
|