Portal: Fortschritt unter Projekte, neuer Tab Funktionen mit News-Editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6dac6aa073
commit
5a09d060a1
7 files changed
+937
-24
No files matched your search
@@ -60,6 +60,7 @@ const paidSession = (id, projectId, extra = {}) => ({
|
||||
test('liste_zustaende', async () => {
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
|
||||
assert.equal(body.consentText, config.funktionen.consentText)
|
||||
assert.equal(body.projects.length, 4)
|
||||
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// 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')],
|
||||
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')))
|
||||
|
||||
// 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(' ; '))
|
||||
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)
|
||||
Reference in new issue
Block a user