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:
co-authored by
Claude Opus 5
parent
e3390c88f4
commit
a88273117c
+16
-1
@@ -6,7 +6,22 @@ node_modules
|
||||
logs
|
||||
sicherungen
|
||||
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
|
||||
playwright-report
|
||||
docs/bilder
|
||||
playwright.config.ts
|
||||
playwright.screenshots.config.ts
|
||||
vitest.config.ts
|
||||
docs
|
||||
devlog
|
||||
*.sh
|
||||
scripts/pruefen.mjs
|
||||
scripts/integrationstest.ts
|
||||
scripts/migrationstest.ts
|
||||
scripts/screenshots.ts
|
||||
|
||||
@@ -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
@@ -26,7 +26,8 @@
|
||||
"db:migrieren": "prisma migrate dev",
|
||||
"db:einspielen": "prisma migrate deploy",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
"db:studio": "prisma studio",
|
||||
"db:demo-seed": "tsx prisma/demo-seed.ts"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -36,8 +36,9 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"vitest.config.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"playwright.config.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
"playwright.screenshots.config.ts"
|
||||
],
|
||||
"exclude": ["node_modules", ".next", "out"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user