chore: Screenshot-Kette und Demo-Seed

npm run screenshots startet eine Wegwerf-Instanz in einem eigenen
Compose-Projekt mit eigener Datenbank, spielt ausschließlich erfundene
Daten ein, fährt die Seiten mit Playwright ab und räumt am Ende alles
wieder weg. Je Seite entstehen vier Bilder: hell und dunkel, Schreibtisch
und Handy. Die Uhr steht fest, damit sich zwischen zwei Läufen nur das
ändert, was sich wirklich geändert hat.

Der Demo-Seed verweigert den Dienst ohne DEMO_MODUS=true — er würde sonst
erfundene Daten in eine echte Datenbank schreiben.

Test- und Werkzeugordner sind jetzt aus dem Docker-Kontext ausgenommen.
Ein Typfehler in scripts/screenshots.ts hatte den Bau des Abbilds
blockiert, obwohl die Datei dort nichts zu suchen hat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 14:29:52 +02:00
co-authored by Claude Opus 5
parent e3390c88f4
commit a88273117c
8 changed files with 503 additions and 3 deletions
+16 -1
View File
@@ -6,7 +6,22 @@ node_modules
logs logs
sicherungen sicherungen
coverage coverage
# Nur für die Entwicklung. Halten den Kontext klein und sorgen dafür, dass ein
# defektes Werkzeugskript nicht den Bau des Abbilds blockiert – geprüft werden
# sie ohnehin von "npm run pruefen".
e2e
screenshots
test
test-results test-results
playwright-report playwright-report
docs/bilder playwright.config.ts
playwright.screenshots.config.ts
vitest.config.ts
docs
devlog devlog
*.sh
scripts/pruefen.mjs
scripts/integrationstest.ts
scripts/migrationstest.ts
scripts/screenshots.ts
+61
View File
@@ -0,0 +1,61 @@
# Wegwerf-Instanz nur für "npm run screenshots".
#
# Eigenes Compose-Projekt (planer-screenshots), eigenes Volume, eigene Ports.
# Sie kommt der Entwicklungs- oder Betriebsinstanz damit nicht in die Quere und
# wird nach dem Lauf samt Volume wieder entfernt.
services:
db:
image: postgres:16-alpine
restart: 'no'
environment:
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo
POSTGRES_DB: demo
TZ: Europe/Berlin
ports:
- '${SCREENSHOT_DB_PORT:-5455}:5432'
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U demo -d demo']
interval: 3s
timeout: 3s
retries: 20
start_period: 10s
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: Europe/Berlin
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app:
build:
context: .
dockerfile: Dockerfile
target: betrieb
restart: 'no'
depends_on:
migration:
condition: service_completed_successfully
environment:
NODE_ENV: production
TZ: Europe/Berlin
INSTANZ: daheim
APP_URL: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
AUTH_SECRET: nur-fuer-die-screenshot-instanz-nicht-geheim
AUTH_TRUST_HOST: 'true'
ANMELDUNG_PASSWORT: 'true'
WEBAUTHN_RP_ID: 127.0.0.1
WEBAUTHN_RP_NAME: Planer
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
DEMO_MODUS: 'true'
ports:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
+2 -1
View File
@@ -26,7 +26,8 @@
"db:migrieren": "prisma migrate dev", "db:migrieren": "prisma migrate dev",
"db:einspielen": "prisma migrate deploy", "db:einspielen": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"db:demo-seed": "tsx prisma/demo-seed.ts"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test'
const PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
/**
* Eigene Konfiguration für die Screenshot-Instanz. Sie startet keinen Server —
* darum kümmert sich scripts/screenshots.ts über Docker Compose.
*/
export default defineConfig({
testDir: './screenshots',
fullyParallel: false,
workers: 1,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: `http://127.0.0.1:${PORT}`,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
})
+172
View File
@@ -0,0 +1,172 @@
/**
* Daten für die Screenshot-Instanz.
*
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
* DEMO_MODUS nicht ausdrücklich auf true steht.
*
* Aufruf: über npm run screenshots, nicht von Hand.
*/
import 'dotenv/config'
import { PrismaPg } from '@prisma/adapter-pg'
import bcrypt from 'bcryptjs'
import { PrismaClient } from '../src/generated/prisma/client.js'
if (process.env.DEMO_MODUS !== 'true') {
console.error(
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
'sonst erfundene Daten in eine echte Datenbank schreiben.',
)
process.exit(1)
}
const verbindung = process.env.DATABASE_URL
if (!verbindung) {
console.error('DATABASE_URL ist nicht gesetzt.')
process.exit(1)
}
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
const BEREICHE = [
{
name: 'Firma',
farbe: '#2563eb',
reihenfolge: 1,
arbeitsfensterVon: 450,
arbeitsfensterBis: 990,
kapazitaetMo: 360,
kapazitaetDi: 360,
kapazitaetMi: 360,
kapazitaetDo: 360,
kapazitaetFr: 240,
kapazitaetSa: 0,
kapazitaetSo: 0,
},
{
name: 'Gewerbe',
farbe: '#7c3aed',
reihenfolge: 2,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1260,
kapazitaetMo: 90,
kapazitaetDi: 90,
kapazitaetMi: 90,
kapazitaetDo: 90,
kapazitaetFr: 60,
kapazitaetSa: 180,
kapazitaetSo: 0,
},
{
name: 'Privat',
farbe: '#16a34a',
reihenfolge: 3,
arbeitsfensterVon: 1020,
arbeitsfensterBis: 1320,
kapazitaetMo: 60,
kapazitaetDi: 60,
kapazitaetMi: 60,
kapazitaetDo: 60,
kapazitaetFr: 120,
kapazitaetSa: 240,
kapazitaetSo: 240,
},
{
name: 'Homelab',
farbe: '#ea580c',
reihenfolge: 4,
arbeitsfensterVon: 1140,
arbeitsfensterBis: 1380,
kapazitaetMo: 0,
kapazitaetDi: 60,
kapazitaetMi: 0,
kapazitaetDo: 60,
kapazitaetFr: 0,
kapazitaetSa: 120,
kapazitaetSo: 60,
},
]
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
const PROTOKOLL = [
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
{
aktion: 'bereich.geaendert',
objektTyp: 'Bereich',
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
},
{
aktion: 'passkey.angelegt',
objektTyp: 'Passkey',
details: { bezeichnung: 'Diensthandy' },
},
]
async function main(): Promise<void> {
console.log('Demo-Daten einspielen.')
for (const bereich of BEREICHE) {
await db.bereich.upsert({
where: { name: bereich.name },
update: bereich,
create: bereich,
})
}
const benutzer = await db.benutzer.upsert({
where: { email: DEMO_EMAIL },
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
create: {
email: DEMO_EMAIL,
name: 'Martina Beispiel',
rolle: 'ADMIN',
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
},
})
await db.auditEintrag.deleteMany({})
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
let versatz = 0
for (const eintrag of PROTOKOLL) {
versatz += 37
await db.auditEintrag.create({
data: {
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
benutzerId: benutzer.id,
benutzerEmail: benutzer.email,
aktion: eintrag.aktion,
objektTyp: eintrag.objektTyp,
details: eintrag.details,
instanz: 'daheim',
},
})
}
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
await db.passkey.create({
data: {
benutzerId: benutzer.id,
credentialId: 'demo-passkey-diensthandy',
publicKey: Buffer.from([0]),
bezeichnung: 'Diensthandy',
gesichert: true,
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
},
})
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
}
main()
.then(async () => {
await db.$disconnect()
})
.catch(async (fehler: unknown) => {
console.error(fehler)
await db.$disconnect()
process.exit(1)
})
+88
View File
@@ -0,0 +1,88 @@
import { mkdir } from 'node:fs/promises'
import { test, type Page } from '@playwright/test'
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Je Seite vier Bilder: hell und dunkel, Schreibtisch und Handy. Die Uhr steht
* auf einem festen Zeitpunkt, damit sich zwischen zwei Läufen nur das ändert,
* was sich wirklich geändert hat.
*/
const ZIEL = 'docs/bilder'
const SCHREIBTISCH = { width: 1440, height: 900 }
const HANDY = { width: 390, height: 844 }
// Dienstag, 17. März 2026, 09:20 Uhr in Berlin.
const FESTE_ZEIT = new Date('2026-03-17T08:20:00.000Z')
const EMAIL = 'martina.beispiel@planer.invalid'
const PASSWORT = 'demo-instanz-nur-fuer-bilder'
type Aufnahme = {
name: string
pfad: string
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
vorbereiten?: (seite: Page) => Promise<void>
}
const AUFNAHMEN: Aufnahme[] = [
{ name: 'anmelden', pfad: '/anmelden' },
{ name: 'start', pfad: '/' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
]
async function anmelden(seite: Page): Promise<void> {
await seite.goto('/anmelden')
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
await seite.getByLabel('Passwort').fill(PASSWORT)
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
await seite.waitForURL('/')
}
test.beforeAll(async () => {
await mkdir(ZIEL, { recursive: true })
})
for (const schema of ['hell', 'dunkel'] as const) {
for (const [geraet, groesse] of [
['schreibtisch', SCHREIBTISCH],
['handy', HANDY],
] as const) {
test(`Bilder: ${schema}, ${geraet}`, async ({ browser }) => {
const kontext = await browser.newContext({
viewport: groesse,
colorScheme: schema === 'dunkel' ? 'dark' : 'light',
deviceScaleFactor: 2,
})
const seite = await kontext.newPage()
await seite.clock.setFixedTime(FESTE_ZEIT)
for (const aufnahme of AUFNAHMEN) {
if (aufnahme.pfad !== '/anmelden') {
// Für alles außer der Anmeldeseite braucht es eine Sitzung.
if (
!(await seite.context().cookies()).some((k) => k.name.includes('session'))
) {
await anmelden(seite)
}
}
await seite.goto(aufnahme.pfad)
await seite.waitForLoadState('networkidle')
await aufnahme.vorbereiten?.(seite)
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
await seite.waitForTimeout(300)
await seite.screenshot({
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
fullPage: true,
})
}
await kontext.close()
})
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Nimmt die Bilder für das Handbuch auf.
*
* Startet eine Wegwerf-Instanz in einem eigenen Compose-Projekt mit eigener
* Datenbank, spielt ausschließlich erfundene Daten ein, fährt die Seiten mit
* Playwright ab und räumt am Ende alles wieder weg — auch wenn unterwegs etwas
* schiefgeht.
*
* An eine Instanz mit echten Daten kommt dieser Ablauf nicht heran: die
* Datenbank entsteht neu, wird nur über ihren eigenen Port angesprochen und
* samt Volume wieder entfernt.
*
* Aufruf: npm run screenshots
*/
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
const PROJEKT = 'planer-screenshots'
const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
const UMGEBUNG = {
...process.env,
SCREENSHOT_APP_PORT: APP_PORT,
SCREENSHOT_DB_PORT: DB_PORT,
}
function meldung(text: string): void {
console.log(`▸ ${text}`)
}
function laufen(
befehl: string,
argumente: string[],
zusatz: Record<string, string> = {},
): number {
const ergebnis = spawnSync(befehl, argumente, {
cwd: WURZEL,
stdio: 'inherit',
env: { ...UMGEBUNG, ...zusatz },
})
return ergebnis.status ?? 1
}
function compose(argumente: string[]): number {
return laufen('docker', ['compose', '-p', PROJEKT, '-f', COMPOSE_DATEI, ...argumente])
}
function node(skript: string, zusatz: Record<string, string>): number {
return laufen(
process.execPath,
[`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript],
zusatz,
)
}
async function wartenAufGesundheit(sekunden: number): Promise<boolean> {
const ziel = `http://127.0.0.1:${APP_PORT}/api/health`
const frist = Date.now() + sekunden * 1000
while (Date.now() < frist) {
try {
const antwort = await fetch(ziel)
if (antwort.ok) {
const inhalt = (await antwort.json()) as { status?: string }
if (inhalt.status === 'ok') return true
}
} catch {
// Noch nicht da, gleich noch einmal.
}
await new Promise((weiter) => setTimeout(weiter, 2000))
}
return false
}
function aufraeumen(): void {
meldung('Wegwerf-Instanz entfernen.')
compose(['down', '-v', '--remove-orphans'])
}
async function main(): Promise<number> {
if (laufen('docker', ['info']) !== 0) {
console.error('Der Docker-Daemon läuft nicht.')
return 1
}
// Reste eines abgebrochenen Laufs zuerst wegräumen.
compose(['down', '-v', '--remove-orphans'])
meldung(`Wegwerf-Instanz bauen und starten (App auf ${APP_PORT}, DB auf ${DB_PORT}).`)
if (compose(['up', '-d', '--build']) !== 0) {
console.error('Die Wegwerf-Instanz ließ sich nicht starten.')
return 1
}
meldung('Auf die Anwendung warten.')
if (!(await wartenAufGesundheit(180))) {
console.error('Die Anwendung ist nicht gesund geworden.')
compose(['logs', '--tail', '50', 'app'])
return 1
}
meldung('Erfundene Daten einspielen.')
const demoUrl = `postgresql://demo:demo@127.0.0.1:${DB_PORT}/demo?schema=public`
if (node('prisma/demo-seed.ts', { DATABASE_URL: demoUrl, DEMO_MODUS: 'true' }) !== 0) {
console.error('Der Demo-Seed ist fehlgeschlagen.')
return 1
}
meldung('Bilder aufnehmen.')
const playwright = laufen(
process.execPath,
[
`${WURZEL}node_modules/@playwright/test/cli.js`,
'test',
'--config',
'playwright.screenshots.config.ts',
],
{ SCREENSHOT_APP_PORT: APP_PORT },
)
if (playwright !== 0) {
console.error('Die Aufnahme ist fehlgeschlagen.')
return 1
}
meldung('Fertig. Die Bilder liegen in docs/bilder/.')
return 0
}
let code = 1
try {
code = await main()
} catch (fehler) {
console.error('Der Screenshot-Lauf ist abgebrochen:', fehler)
} finally {
aufraeumen()
}
process.exit(code)
+2 -1
View File
@@ -36,8 +36,9 @@
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
"vitest.config.ts", "vitest.config.ts",
".next/dev/types/**/*.ts",
"playwright.config.ts", "playwright.config.ts",
".next/dev/types/**/*.ts" "playwright.screenshots.config.ts"
], ],
"exclude": ["node_modules", ".next", "out"] "exclude": ["node_modules", ".next", "out"]
} }