import { chromium } from '@playwright/test'; import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; /** * Der Abnahmelauf. * * Anders als die Prüfungen der einzelnen Bausteine läuft dieser hier gegen * eine **frische, leere Datenbank** in einem Wegwerfverzeichnis. Er beweist * damit nicht nur, dass die Anwendung funktioniert, sondern dass sie sich aus * dem Nichts aufsetzen lässt: Migrationen laufen an, das erste Ausbilderkonto * entsteht auf der Kommandozeile, alles Weitere über die Oberfläche. * * Am Ende wird gesichert, absichtlich etwas kaputtgemacht und zurückgespielt. * Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. * * node scripts/pruefungen/abnahme.mjs * * Voraussetzung: `npm run build` ist gelaufen. */ const PORT = Number(process.env.LERNBAR_ABNAHME_PORT ?? 3399); const BASIS = `http://localhost:${PORT}`; const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'abnahme-ausbilder' }; const AZUBI = { name: 'Tim Vogt', email: 'tim.vogt@betrieb.test', pw: 'abnahme-azubi-2026' }; let fehler = 0; const protokoll = []; function pruefe(bedingung, text, zusatz = '') { console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); protokoll.push({ erfuellt: Boolean(bedingung), text, zusatz }); if (!bedingung) fehler += 1; } function abschnitt(titel) { console.log(`\n${titel}`); protokoll.push({ abschnitt: titel }); } // --- Wegwerfumgebung ------------------------------------------------------- const daten = fs.mkdtempSync(path.join(os.tmpdir(), 'lernbar-abnahme-')); const umgebung = { ...process.env, LERNBAR_DATEN: daten, PORT: String(PORT), TZ: 'Europe/Berlin' }; console.log(`Wegwerfverzeichnis: ${daten}`); let server = null; let browser = null; /** * Den Server beenden – wirklich. * * Unter Windows beendet `kill()` auf einem über die Eingabeaufforderung * gestarteten Prozess nur diese, nicht den Node-Prozess darunter. Deshalb wird * hier ohne Eingabeaufforderung gestartet und zur Sicherheit der ganze Baum * abgeräumt. Ein überlebender Server aus einem früheren Lauf ist kein * Schönheitsfehler: Er belegt den Port, der nächste Lauf bekommt ihn nicht, * und dann prüft man stundenlang gegen den alten Datenbestand. */ function serverBeenden() { if (!server) return; try { if (process.platform === 'win32') { spawnSync('taskkill', ['/PID', String(server.pid), '/T', '/F'], { stdio: 'ignore' }); } else { server.kill('SIGTERM'); } } catch { /* egal */ } server = null; } function aufraeumen() { serverBeenden(); try { fs.rmSync(daten, { recursive: true, force: true }); } catch { // Unter Windows hält der Serverprozess die Datei manchmal noch kurz. } } process.on('exit', aufraeumen); /** Antwortet dort schon jemand? Dann ist es nicht unser Server. */ async function portBelegt() { try { await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(2000) }); return true; } catch { return false; } } /** Wartet, bis der Server antwortet. */ async function serverBereit(sekunden = 180) { for (let versuch = 0; versuch < sekunden * 2; versuch += 1) { if (server?.exitCode !== null && server?.exitCode !== undefined) return false; try { const antwort = await fetch(`${BASIS}/api/gesundheit`); if (antwort.ok) return true; } catch { /* noch nicht da */ } await new Promise((weiter) => setTimeout(weiter, 500)); } return false; } function laufen(befehl, argumente, zusatz = {}) { return spawnSync(befehl, argumente, { env: umgebung, encoding: 'utf8', shell: process.platform === 'win32', ...zusatz, }); } try { // --- A1 Aufsetzen aus dem Nichts ----------------------------------------- abschnitt('A1 · Aus dem Nichts aufsetzbar'); if (await portBelegt()) { throw new Error( `Auf ${BASIS} antwortet bereits etwas. Dieser Lauf braucht den Port für sich – ` + 'sonst prüft er gegen einen fremden Datenbestand. Bitte beenden oder ' + 'LERNBAR_ABNAHME_PORT setzen.', ); } const konto = laufen('npx', [ 'tsx', 'scripts/konto-anlegen.ts', '--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw, ]); pruefe(konto.status === 0, 'Das erste Ausbilderkonto entsteht auf der Kommandozeile', (konto.stderr || '').trim().split('\n')[0] || 'ohne Fehler'); pruefe( fs.existsSync(path.join(daten, 'lernbar.sqlite')), 'Die Datenbank wurde angelegt und die Migrationen liefen an', ); // Ohne Eingabeaufforderung starten: nur dann lässt sich der Prozess später // auch wirklich beenden. server = spawn(process.execPath, [path.join('node_modules', 'next', 'dist', 'bin', 'next'), 'start', '-p', String(PORT)], { env: umgebung, stdio: ['ignore', 'pipe', 'pipe'], }); let serverAusgabe = ''; server.stdout.on('data', (stueck) => (serverAusgabe += stueck)); server.stderr.on('data', (stueck) => (serverAusgabe += stueck)); const laeuft = await serverBereit(); pruefe(laeuft, 'Die Anwendung startet und meldet sich gesund', laeuft ? '' : serverAusgabe.slice(-400)); if (!laeuft) throw new Error('Server nicht erreichbar.'); // Die Zusage lautet: Bei einem Inhaltsfehler startet die Anwendung nicht und // nennt Datei und Zeile. Das prüft man, indem man einen Fehler einbaut – // nicht, indem man in der Startausgabe nach einem Wort sucht. const heil = laufen('npx', ['tsx', 'scripts/inhalte-pruefen.ts']); pruefe( heil.status === 0, 'Heile Inhalte werden ohne Beanstandung gelesen', ((heil.stdout || '').match(/Inhalte in Ordnung/) ?? ['—'])[0], ); const kaputteInhalte = path.join(daten, 'kaputte-inhalte'); fs.cpSync(path.join(process.cwd(), 'content'), kaputteInhalte, { recursive: true }); // Ein Verweis auf eine Rahmenplanposition, die es nicht gibt. Das ist der // Fehler, der ohne Prüfung erst Monate später auffiele – in der Auswertung. const opfer = path.join(kaputteInhalte, 'handouts', 'N3-subnetting-rechnen.md'); const zeilen = fs.readFileSync(opfer, 'utf8').split(/\r?\n/); const stelle = zeilen.findIndex((zeile) => zeile.startsWith('rahmenplan:')); // Wohlgeformt, aber ohne Entsprechung – so greift die Querprüfung zwischen // den Dateien und nicht bloß die Formatprüfung. zeilen[stelle] = 'rahmenplan: [B2, C9]'; fs.writeFileSync(opfer, zeilen.join('\n'), 'utf8'); const erwarteteZeile = stelle + 1; const kaputt = spawnSync('npx', ['tsx', 'scripts/inhalte-pruefen.ts'], { env: { ...umgebung, LERNBAR_INHALTE: kaputteInhalte }, encoding: 'utf8', shell: process.platform === 'win32', }); const meldung = `${kaputt.stdout ?? ''}${kaputt.stderr ?? ''}`; pruefe(kaputt.status !== 0, 'Ein Inhaltsfehler lässt die Prüfung fehlschlagen', `Ende ${kaputt.status}`); pruefe( /N3-subnetting-rechnen\.md/.test(meldung), 'Die Meldung nennt die Datei', (meldung.match(/.*N3-subnetting-rechnen\.md.*/) ?? ['—'])[0].trim().slice(0, 100), ); pruefe( meldung.includes(`N3-subnetting-rechnen.md:${erwarteteZeile}`), 'Die Meldung nennt die richtige Zeile', `erwartet Zeile ${erwarteteZeile}`, ); pruefe(meldung.includes('C9'), 'Die Meldung nennt den fehlerhaften Wert'); pruefe(meldung.includes('rahmenplan.yaml'), 'Die Meldung sagt, wo die gültigen Werte stehen'); browser = await chromium.launch(); // --- A2 Zwei Rollen, keine Selbstregistrierung --------------------------- abschnitt('A2 · Zwei Rollen, keine Selbstregistrierung'); const gast = await browser.newContext({ locale: 'de-DE' }); const gastSeite = await gast.newPage(); gastSeite.setDefaultTimeout(60000); await gastSeite.goto(`${BASIS}/anmelden`); const anmeldetext = await gastSeite.locator('body').innerText(); pruefe(/keine Selbstregistrierung/i.test(anmeldetext), 'Die Anmeldeseite bietet keine Registrierung'); const ungeschuetzt = await gast.request.get(`${BASIS}/berichtsheft`, { maxRedirects: 0 }); pruefe( ungeschuetzt.status() >= 300 && ungeschuetzt.status() < 400, 'Ohne Anmeldung führt jeder Pfad zur Anmeldung', `HTTP ${ungeschuetzt.status()}`, ); await gast.close(); const ausbilder = await browser.newContext({ locale: 'de-DE' }); const a = await ausbilder.newPage(); a.setDefaultTimeout(60000); await a.goto(`${BASIS}/anmelden`); await a.fill('#email', AUSBILDER.email); await a.fill('#passwort', AUSBILDER.pw); await a.click('button[type=submit]'); await a.waitForURL(`${BASIS}/`); pruefe(true, 'Der Ausbilder meldet sich an'); // --- A3 Der Ausbilder legt ein Azubikonto an ----------------------------- abschnitt('A3 · Der Ausbilder legt Konten an'); await a.goto(`${BASIS}/verwaltung`); await a.waitForSelector('#neu-name'); await a.fill('#neu-name', AZUBI.name); await a.fill('#neu-email', AZUBI.email); await a.fill('#neu-passwort', AZUBI.pw); await a.fill('#neu-beginn', '2026-08-03'); await a.click('form:has(#neu-name) button[type=submit]'); await a.waitForSelector(`text=${AZUBI.name}`, { timeout: 20000 }); const verwaltungstext = await a.locator('body').innerText(); pruefe(verwaltungstext.includes(AZUBI.name), 'Der Azubi steht in der Verwaltung'); // Das Passwort darf nirgends wieder auftauchen – weder in der Ansicht noch // in der Datenbank. await a.reload(); const nachNeuladen = await a.content(); pruefe(!nachNeuladen.includes(AZUBI.pw), 'Das Passwort ist in der Oberfläche nirgends mehr zu sehen'); const { default: Sqlite } = await import('better-sqlite3'); // Bewusst **nicht** readonly: Eine Lesesitzung auf einer WAL-Datenbank sieht // ohne Schreibrecht auf die Begleitdateien unter Umständen nur den letzten // Prüfpunkt – und damit ein frisch angelegtes Konto noch nicht. const pruefDb = new Sqlite(path.join(daten, 'lernbar.sqlite')); const alleKonten = pruefDb.prepare('select email, rolle from benutzer').all(); const gespeichert = pruefDb.prepare('select passwort_hash from benutzer where email = ?').get(AZUBI.email); pruefDb.close(); if (!gespeichert) { console.log(` (in der Datenbank stehen: ${alleKonten.map((k) => `${k.email} [${k.rolle}]`).join(', ') || 'keine'})`); } pruefe( Boolean(gespeichert) && !String(gespeichert.passwort_hash).includes(AZUBI.pw), 'In der Datenbank steht kein Klartextpasswort', ); pruefe( String(gespeichert?.passwort_hash ?? '').startsWith('$argon2id$'), 'Das Passwort liegt als Argon2id-Hash', String(gespeichert?.passwort_hash ?? '').slice(0, 30), ); // --- A4 Der Azubi arbeitet ------------------------------------------------ abschnitt('A4 · Der Azubi arbeitet'); const azubi = await browser.newContext({ locale: 'de-DE' }); const z = await azubi.newPage(); z.setDefaultTimeout(60000); await z.goto(`${BASIS}/anmelden`); await z.fill('#email', AZUBI.email); await z.fill('#passwort', AZUBI.pw); await z.click('button[type=submit]'); const angemeldet = await z.waitForURL(`${BASIS}/`, { timeout: 15000 }).then(() => true, () => false); pruefe(angemeldet, 'Der Azubi meldet sich mit dem vergebenen Passwort an'); if (!angemeldet) throw new Error('Anmeldung des Azubis fehlgeschlagen.'); await z.goto(`${BASIS}/handouts/N3`); await z.waitForSelector('h1'); await z.click('button:has-text("Als gelesen markieren")'); await z.waitForSelector('button:has-text("Doch noch nicht gelesen")', { timeout: 15000 }); pruefe(true, 'Ein Handout lässt sich als gelesen markieren'); await z.goto(`${BASIS}/karten/lernen`); const hatKarte = (await z.locator('.lernkarte').count()) > 0; if (hatKarte) { await z.click('.lernkarte'); await z.click('.bewerten-sitzt'); await z.waitForTimeout(600); const rueck = await z.locator('.lernmodus-rueckmeldung').innerText(); pruefe(/kommt/.test(rueck), 'Eine Merkkarte lässt sich bewerten', rueck.trim()); } else { pruefe(false, 'Eine Merkkarte lässt sich bewerten', 'kein Stapel fällig'); } // --- A5 Aufgabe abgeben und abnehmen ------------------------------------- abschnitt('A5 · Aufgabe abgeben und abnehmen'); await z.goto(`${BASIS}/aufgaben/1.1`); await z.waitForSelector('#aufgaben-notiz'); await z.fill( '#aufgaben-notiz', 'Arbeitsplatz nach Checkliste übergeben: Gerät angeschlossen, Anmeldung geprüft, ' + 'Drucker verbunden, Nutzer eingewiesen, Ticket geschlossen.', ); await z.click('button:has-text("Notizen speichern")'); await z.waitForSelector('text=/gespeichert/i', { timeout: 20000 }); pruefe(true, 'Der Azubi hält seine Arbeit in Notizen fest'); await z.click('button:has-text("Zur Abnahme")'); await z.waitForTimeout(1500); await z.reload(); pruefe( await z.isDisabled('button:has-text("Zur Abnahme")'), 'Der Azubi meldet die Aufgabe zur Abnahme', ); // Der Ausbilder nimmt ab. await a.goto(`${BASIS}/aufgaben`); await a.waitForSelector('h1'); const zurAbnahme = await a.$('a[href*="/aufgaben/1.1?azubi="]'); pruefe(Boolean(zurAbnahme), 'Die Aufgabe erscheint beim Ausbilder zur Abnahme'); if (zurAbnahme) { await zurAbnahme.click(); await a.waitForSelector('.kriterien', { timeout: 15000 }); for (const kasten of await a.$$('.kriterien input[type=checkbox]')) await kasten.check(); await a.fill('#bewertung', 'gut'); await a.fill('#rueckmeldung', 'Sauber dokumentiert, Einweisung nachvollziehbar.'); await a.click('button:has-text("Bewertung speichern")'); await a.waitForSelector('text=/gespeichert/i', { timeout: 20000 }); await a.click('button:has-text("Aufgabe abnehmen")'); await a.waitForSelector('button:has-text("Bereits abgenommen")', { timeout: 20000 }); pruefe(true, 'Der Ausbilder nimmt die Aufgabe ab'); // Und danach ist sie für den Azubi gesperrt. await z.goto(`${BASIS}/aufgaben/1.1`); await z.waitForSelector('h1'); pruefe( await z.isDisabled('#aufgaben-notiz'), 'Für den Azubi ist die abgenommene Aufgabe gesperrt', ); // Eine abgenommene Aufgabe ist der belastbare Nachweis in der Abdeckung. await a.goto(`${BASIS}/auswertung`); await a.waitForSelector('h1'); const abdeckung = await a.locator('body').innerText(); pruefe(/vermittelt/.test(abdeckung), 'Die Abnahme schlägt in der Rahmenplanabdeckung durch'); pruefe(/ohne Inhalt|kein Inhalt/i.test(abdeckung), 'Lücken im Lehrgang werden getrennt ausgewiesen'); } // --- A6 Rechte ----------------------------------------------------------- abschnitt('A6 · Rechte serverseitig'); const fremd = await azubi.request.get(`${BASIS}/verwaltung`, { maxRedirects: 0 }); pruefe( fremd.status() >= 300 && fremd.status() < 400, 'Der Azubi kommt nicht in die Verwaltung', `HTTP ${fremd.status()}`, ); // --- A7 Berichtsheft ----------------------------------------------------- abschnitt('A7 · Berichtsheft'); await z.goto(`${BASIS}/berichtsheft`); await z.waitForSelector('h1'); // Auf einer frischen Datenbank gibt es noch kein Blatt – der Azubi legt das // der laufenden Woche an. Genau so fängt er im Betrieb auch an. const anlegen = await z.$('button:has-text("Blatt für diese Woche anlegen")'); if (anlegen) { await anlegen.click(); await z.waitForSelector('.blattzeile-link, h1', { timeout: 30000 }); await z.goto(`${BASIS}/berichtsheft`); await z.waitForSelector('h1'); } pruefe( (await z.locator('.blattzeile-link').count()) > 0, 'Der Azubi legt das Wochenblatt der laufenden Woche an', ); const blatt = await z.$('.blattzeile-link'); let blattAdresse = null; if (blatt) { await blatt.click(); // Auf die Adresse warten, nicht auf eine Überschrift: Die steht auch auf // der Liste, von der wir kommen. Sonst zeigt `url()` noch dorthin – und // die Anfrage landet auf der Sammelroute statt auf dem einzelnen Blatt. await z.waitForURL(/\/berichtsheft\/[0-9a-f-]{36}$/, { timeout: 30000 }); pruefe(true, 'Ein Wochenblatt lässt sich öffnen'); blattAdresse = z.url(); const pdf = await azubi.request.get(`${z.url()}/pdf`); const puffer = await pdf.body(); pruefe( pdf.status() === 200 && puffer.subarray(0, 5).toString() === '%PDF-', 'Das Wochenblatt kommt als PDF heraus', `HTTP ${pdf.status()}, ${puffer.byteLength} Bytes${ puffer.subarray(0, 5).toString() === '%PDF-' ? '' : `: „${puffer.toString('utf8').slice(0, 60)}"` }`, ); } else { pruefe(false, 'Ein Wochenblatt lässt sich öffnen', 'keine Blattzeile gefunden'); } // --- A8 Module abschalten ------------------------------------------------- abschnitt('A8 · Module abschalten'); // Über die Oberfläche, wie im Betrieb: Verwaltung → Module. const umschalten = async (modul) => { await a.goto(`${BASIS}/verwaltung/module`); await a.click(`form:has(input[name="modul"][value="${modul}"]) button[type=submit]`); await a.waitForLoadState('networkidle'); }; const zustandIn = async (modul) => { await a.goto(`${BASIS}/verwaltung/module`); // Die Statusmarke steht direkt neben der Überschrift des Moduls. return (await a.locator(`#modul-${modul} + .marke-status`).innerText()).trim(); }; await umschalten('berichtsheft'); await umschalten('karten'); pruefe((await zustandIn('berichtsheft')).includes('aus'), 'Der Ausbilder schaltet das Berichtsheft ab'); await z.goto(`${BASIS}/`); pruefe((await z.locator('nav a[href="/berichtsheft"]').count()) === 0, 'Das Berichtsheft verschwindet aus der Navigation'); const seiteAus = await azubi.request.get(`${BASIS}/berichtsheft`); const ausbilderSeiteAus = await ausbilder.request.get(`${BASIS}/berichtsheft`); pruefe( seiteAus.status() === 404 && ausbilderSeiteAus.status() === 404, 'Die Seite ist für beide Rollen gesperrt, nicht nur ausgeblendet', `Azubi HTTP ${seiteAus.status()}, Ausbilder HTTP ${ausbilderSeiteAus.status()}`, ); if (blattAdresse) { const pdfAus = await azubi.request.get(`${blattAdresse}/pdf`); pruefe(pdfAus.status() === 404, 'Auch das PDF ist gesperrt', `HTTP ${pdfAus.status()}`); } await z.goto(`${BASIS}/handouts/N3`); pruefe( (await z.locator('text=Merkkarten zu diesem Handout').count()) === 0, 'Ohne Merkkarten zeigt das Handout keine Karten', ); const lernstandAus = await azubi.request.post(`${BASIS}/api/lernstand`, { data: { eintraege: [] } }); pruefe(lernstandAus.status() === 410, 'Ein Gerät mit gesammelten Bewertungen erfährt, dass es sie verwerfen soll', `HTTP ${lernstandAus.status()}`); await umschalten('berichtsheft'); await umschalten('karten'); if (blattAdresse) { const wieder = await azubi.request.get(blattAdresse); pruefe(wieder.status() === 200, 'Nach dem Einschalten ist das Wochenblatt unverändert wieder da', `HTTP ${wieder.status()}`); } // --- A9 Sicherung und Rückspielung --------------------------------------- abschnitt('A9 · Sicherung zurückspielen'); // Pfade für bash mit Schrägstrichen: Ein Backslash ist dort ein Fluchtzeichen. const alsPosix = (pfad) => pfad.split(path.sep).join('/'); const sicherung = laufen('bash', ['scripts/im-container/sicherung.sh', alsPosix(path.join(daten, 'sicherungen'))]); const archiv = (sicherung.stdout || '').trim().split('\n').pop(); pruefe( sicherung.status === 0 && archiv && fs.existsSync(archiv), 'Die Sicherung läuft durch', archiv ? path.basename(archiv) : (sicherung.stderr || '').trim(), ); // Jetzt absichtlich kaputtmachen: Der Azubi verschwindet. Vorher den Server // beenden – zurückgespielt wird bei gestoppter Anwendung, so steht es auch // in der Anleitung. serverBeenden(); await new Promise((weiter) => setTimeout(weiter, 2500)); const { default: Datenbank } = await import('better-sqlite3'); const dbPfad = path.join(daten, 'lernbar.sqlite'); const vorher = (() => { const db = new Datenbank(dbPfad); const zahl = db.prepare('select count(*) n from benutzer').get().n; db.prepare('delete from benutzer where email = ?').run(AZUBI.email); const nachher = db.prepare('select count(*) n from benutzer').get().n; db.close(); return { zahl, nachher }; })(); pruefe(vorher.nachher < vorher.zahl, 'Zum Beweis wird der Datenbestand beschädigt', `${vorher.zahl} → ${vorher.nachher} Konten`); const zurueck = laufen('bash', ['scripts/im-container/wiederherstellen.sh', alsPosix(archiv ?? ''), '--ohne-rueckfrage']); pruefe(zurueck.status === 0, 'Die Rückspielung läuft durch', (zurueck.stderr || '').trim() || 'ohne Fehler'); const wiederDa = (() => { const db = new Datenbank(dbPfad); const treffer = db.prepare('select name from benutzer where email = ?').get(AZUBI.email); const karten = db.prepare('select count(*) n from karten_verlauf').get().n; db.close(); return { treffer, karten }; })(); pruefe(wiederDa.treffer?.name === AZUBI.name, 'Der gelöschte Azubi ist wieder da', wiederDa.treffer?.name ?? 'fehlt'); pruefe(wiederDa.karten >= 0, 'Auch der Lernverlauf kam mit zurück', `${wiederDa.karten} Einträge`); const verdraengt = fs.readdirSync(daten).filter((name) => name.startsWith('verdraengt-')); pruefe(verdraengt.length === 1, 'Der überschriebene Stand wurde zur Seite gelegt, nicht gelöscht', verdraengt[0] ?? '—'); } catch (ausnahme) { console.error('\nAbbruch:', ausnahme instanceof Error ? ausnahme.message : ausnahme); fehler += 1; } finally { await browser?.close(); } // --- Protokoll ------------------------------------------------------------- const zeilen = ['# Abnahmeprotokoll', '', `Erzeugt am ${new Date().toISOString().slice(0, 16).replace('T', ' ')} Uhr`, '', 'Erzeugt von `scripts/pruefungen/abnahme.mjs` gegen eine **frische, leere Datenbank**.', '']; for (const eintrag of protokoll) { if (eintrag.abschnitt) zeilen.push('', `## ${eintrag.abschnitt}`, ''); else zeilen.push(`- ${eintrag.erfuellt ? '✓' : '✗'} ${eintrag.text}${eintrag.zusatz ? ` — ${eintrag.zusatz}` : ''}`); } zeilen.push('', `**${protokoll.filter((e) => e.erfuellt).length} von ${protokoll.filter((e) => 'erfuellt' in e).length} Prüfungen erfüllt.**`, ''); fs.writeFileSync(path.join(process.cwd(), 'docs', 'ABNAHME.md'), zeilen.join('\n'), 'utf8'); console.log('\nProtokoll: docs/ABNAHME.md'); console.log(`${fehler === 0 ? '✓ Abnahme bestanden.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`); process.exit(fehler === 0 ? 0 : 1);