diff --git a/.dockerignore b/.dockerignore index 9561351..0dafacd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/docker-compose.screenshots.yml b/docker-compose.screenshots.yml new file mode 100644 index 0000000..b92667c --- /dev/null +++ b/docker-compose.screenshots.yml @@ -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' diff --git a/package.json b/package.json index 5190880..66aa128 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts new file mode 100644 index 0000000..1c0fcda --- /dev/null +++ b/playwright.screenshots.config.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', + }, +}) diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts new file mode 100644 index 0000000..2b5592c --- /dev/null +++ b/prisma/demo-seed.ts @@ -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 { + 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) + }) diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts new file mode 100644 index 0000000..067f83f --- /dev/null +++ b/screenshots/bilder.spec.ts @@ -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 +} + +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 { + 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() + }) + } +} diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts new file mode 100644 index 0000000..ede8135 --- /dev/null +++ b/scripts/screenshots.ts @@ -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 = {}, +): 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): number { + return laufen( + process.execPath, + [`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript], + zusatz, + ) +} + +async function wartenAufGesundheit(sekunden: number): Promise { + 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 { + 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) diff --git a/tsconfig.json b/tsconfig.json index e59dc59..519efb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] }