import { chromium } from '@playwright/test'; /** * Durchstich für Baustein 10 – PWA, Offlinebetrieb, Druck. * * Die Zusage dieses Bausteins ist schmal und genau: Der Azubi soll ohne Netz * weiterlernen können, und was er dabei bewertet, darf nicht verloren gehen. * Gleichzeitig darf **nichts** offline liegen, was hinter einer eigenen * Rechteprüfung steht – Musterlösungen und Bewertungen gehören nicht in den * Browserspeicher. * * Geprüft wird gegen den laufenden Server, mit echtem Abschalten des Netzes. */ const BASIS = process.env.LERNBAR_BASIS ?? 'http://localhost:3000'; const AZUBI = { email: 'jonas.berger@betrieb.de', pw: 'azubi-start-2026' }; let fehler = 0; function pruefe(bedingung, text, zusatz = '') { console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`); if (!bedingung) fehler += 1; } const browser = await chromium.launch(); const kontext = await browser.newContext({ locale: 'de-DE' }); const seite = await kontext.newPage(); async function anmelden() { await seite.goto(`${BASIS}/anmelden`); await seite.fill('#email', AZUBI.email); await seite.fill('#passwort', AZUBI.pw); await seite.click('button[type=submit]'); await seite.waitForURL(`${BASIS}/`); } /** Wartet, bis der Dienstarbeiter das Ruder übernommen hat. */ async function dienstarbeiterBereit() { return seite.evaluate(async () => { if (!('serviceWorker' in navigator)) return false; const registrierung = await navigator.serviceWorker.ready; // `ready` sagt nur „installiert". Erst ein Controller fängt Anfragen ab. if (navigator.serviceWorker.controller) return true; await new Promise((fertig) => { navigator.serviceWorker.addEventListener('controllerchange', fertig, { once: true }); window.setTimeout(fertig, 5000); }); return Boolean(navigator.serviceWorker.controller || registrierung.active); }); } // --- 1. Die Anwendung lässt sich installieren ------------------------------ console.log('\n1. Installierbar'); const manifest = await kontext.request.get(`${BASIS}/manifest.webmanifest`); pruefe(manifest.status() === 200, 'Das Manifest wird ausgeliefert', `HTTP ${manifest.status()}`); const manifestInhalt = await manifest.json().catch(() => null); pruefe(manifestInhalt?.name?.includes('Lern-Bar'), 'Es trägt den Namen der Anwendung'); pruefe(manifestInhalt?.display === 'standalone', 'Es startet als eigenständige Anwendung'); pruefe( Array.isArray(manifestInhalt?.icons) && manifestInhalt.icons.some((s) => s.purpose === 'maskable'), 'Es enthält ein Symbol für den Beschnitt unter Android', ); for (const datei of ['/symbol-192.png', '/symbol-512.png', '/symbol-maskabel-512.png', '/sw.js']) { const antwort = await kontext.request.get(`${BASIS}${datei}`); pruefe(antwort.status() === 200, `${datei} liegt bereit`, `HTTP ${antwort.status()}`); } // Nichts davon darf von außen kommen – die Anwendung läuft in einem Netz // ohne Internet. await anmelden(); const fremdeQuellen = await seite.evaluate(() => [...document.querySelectorAll('script[src], link[href], img[src]')] .map((element) => element.getAttribute('src') ?? element.getAttribute('href') ?? '') .filter((adresse) => /^https?:\/\//.test(adresse) && !adresse.startsWith(location.origin)), ); pruefe(fremdeQuellen.length === 0, 'Keine Quelle von außerhalb', fremdeQuellen.join(', ') || 'keine'); // --- 2. Der Dienstarbeiter übernimmt --------------------------------------- console.log('\n2. Dienstarbeiter'); pruefe(await dienstarbeiterBereit(), 'Der Dienstarbeiter ist eingerichtet und steuert die Seite'); // Lernmaterial einmal ansehen, damit es im Speicher landet. await seite.goto(`${BASIS}/handouts/N3`); await seite.waitForSelector('h1'); await seite.goto(`${BASIS}/karten/lernen`); await seite.waitForSelector('h1, .lernkarte'); // Und etwas, das **nicht** in den Speicher darf. await seite.goto(`${BASIS}/kontrollen`); await seite.waitForSelector('h1'); const imSpeicher = await seite.evaluate(async () => { const namen = await caches.keys(); const pfade = []; for (const name of namen) { const speicher = await caches.open(name); for (const anfrage of await speicher.keys()) pfade.push(new URL(anfrage.url).pathname); } return pfade; }); pruefe(imSpeicher.includes('/handouts/N3'), 'Das gelesene Handout liegt im Speicher'); pruefe(imSpeicher.includes('/karten/lernen'), 'Der Lernmodus liegt im Speicher'); // Das ist die eigentliche Zusage – hier wird eine Regel geprüft, keine Laune. const verboten = imSpeicher.filter((pfad) => /^\/(kontrollen|berichtsheft|feedback|auswertung|aufgaben|checklisten|verwaltung|anhaenge)/.test(pfad), ); pruefe( verboten.length === 0, 'Nichts Bewertungsrelevantes liegt im Browserspeicher', verboten.join(', ') || 'nichts', ); pruefe( !imSpeicher.some((pfad) => pfad.endsWith('/pdf')), 'Kein PDF liegt im Browserspeicher', ); // --- 3. Ohne Netz -------------------------------------------------------- console.log('\n3. Ohne Netz'); await kontext.setOffline(true); const handoutOffline = await seite.goto(`${BASIS}/handouts/N3`).catch(() => null); pruefe(handoutOffline !== null && handoutOffline.ok(), 'Das gelesene Handout öffnet sich ohne Netz'); pruefe( (await seite.locator('h1').innerText().catch(() => '')).length > 0, 'Es ist wirklich der Inhalt und keine Fehlerseite', await seite.locator('h1').innerText().catch(() => '—'), ); const lernenOffline = await seite.goto(`${BASIS}/karten/lernen`).catch(() => null); pruefe(lernenOffline !== null && lernenOffline.ok(), 'Der Lernmodus öffnet sich ohne Netz'); // Die Netzleiste sagt Bescheid. await seite.waitForTimeout(500); const leiste = await seite.locator('.netzleiste').count(); pruefe(leiste > 0, 'Die Leiste „Ohne Netz" erscheint'); // Eine Seite, die nie im Speicher war, landet auf der Offlineseite. const unbekannt = await seite.goto(`${BASIS}/kontrollen`).catch(() => null); const unbekannterText = await seite.locator('body').innerText().catch(() => ''); pruefe( unbekannterText.includes('Kein Netz'), 'Eine nicht gespeicherte Seite zeigt die Offlineseite', `HTTP ${unbekannt?.status() ?? '—'}`, ); // --- 4. Bewertungen gehen nicht verloren ----------------------------------- console.log('\n4. Bewertungen ohne Netz'); await seite.goto(`${BASIS}/karten/lernen`); const hatKarte = (await seite.locator('.lernkarte').count()) > 0; if (!hatKarte) { console.log(' – heute ist keine Karte fällig, Warteschlange übersprungen'); } else { await seite.click('.lernkarte'); await seite.waitForSelector('.bewerten-knopf'); await seite.click('.bewerten-sitzt'); await seite.waitForTimeout(800); const schlange = await seite.evaluate(() => Object.keys(localStorage) .filter((s) => s.startsWith('lernbar-warteschlange-')) .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), ); pruefe(schlange.length >= 1, 'Die Bewertung liegt in der Warteschlange', `${schlange.length} Eintrag/Einträge`); pruefe( schlange.every((e) => e.karteId && e.bewertung && /^\d{4}-\d{2}-\d{2}$/.test(e.erfasstAm)), 'Jeder Eintrag trägt Karte, Bewertung und den Tag der Bewertung', ); const gemeldet = await seite.locator('.lernmodus-rueckmeldung').innerText(); pruefe(/gemerkt/.test(gemeldet), 'Der Azubi sieht, dass die Bewertung gemerkt wurde', gemeldet.trim()); // --- Und wieder im Netz --- console.log('\n5. Wieder im Netz'); // Wichtig: auf **derselben** Seite bleiben. Die Verbindung kehrt zurück, // während der Azubi noch im Lernmodus steht – genau dort muss die // Bestätigung erscheinen. Wer vorher die Seite wechselt, sieht sie nie, // weil die Schlange dann schon leer ist. await kontext.setOffline(false); const bestaetigung = await seite .locator('.netzleiste') .filter({ hasText: 'nachgereicht' }) .first() .innerText({ timeout: 15000 }) .catch(() => ''); pruefe(/nachgereicht/.test(bestaetigung), 'Der Azubi bekommt die Bestätigung zu sehen', bestaetigung.trim() || '—'); const nachher = await seite.evaluate(() => Object.keys(localStorage) .filter((s) => s.startsWith('lernbar-warteschlange-')) .flatMap((s) => JSON.parse(localStorage.getItem(s) ?? '[]')), ); pruefe(nachher.length === 0, 'Die Warteschlange ist abgearbeitet', `${nachher.length} übrig`); // Und die Bewertung ist wirklich angekommen, nicht nur verschwunden. await seite.goto(`${BASIS}/karten`); await seite.waitForSelector('h1'); const uebersicht = await seite.locator('body').innerText(); pruefe( !/0 von \d+ Karten/.test(uebersicht) || /sitzt/i.test(uebersicht), 'Die Übersicht kennt den neuen Lernstand', ); } await kontext.setOffline(false); // --- 6. Die Schnittstelle prüft die Rechte --------------------------------- console.log('\n6. Rechte an der Schnittstelle'); const ohneAnmeldung = await browser.newContext(); const abgewiesen = await ohneAnmeldung.request.post(`${BASIS}/api/lernstand`, { data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, }); pruefe(abgewiesen.status() === 401, 'Ohne Anmeldung: abgewiesen', `HTTP ${abgewiesen.status()}`); await ohneAnmeldung.close(); const alsAusbilder = await browser.newContext({ locale: 'de-DE' }); const ausbilderSeite = await alsAusbilder.newPage(); await ausbilderSeite.goto(`${BASIS}/anmelden`); await ausbilderSeite.fill('#email', 'fw@wildeflorian.de'); await ausbilderSeite.fill('#passwort', 'ausbilder-start'); await ausbilderSeite.click('button[type=submit]'); await ausbilderSeite.waitForURL(`${BASIS}/`); const alsAusbilderAbgewiesen = await alsAusbilder.request.post(`${BASIS}/api/lernstand`, { data: { eintraege: [{ karteId: 'N3-1', bewertung: 'sitzt', erfasstAm: '2026-09-11' }] }, }); pruefe( alsAusbilderAbgewiesen.status() === 403, 'Der Ausbilder darf den Lernstand nicht führen', `HTTP ${alsAusbilderAbgewiesen.status()}`, ); await alsAusbilder.close(); // --- 7. Druck -------------------------------------------------------------- console.log('\n7. Druck'); await seite.goto(`${BASIS}/handouts/N3`); await seite.waitForSelector('h1'); // Die dunkle Fassung einstellen – und dann drucken. Das ist der Fall, in dem // früher schwarze Flächen aufs Papier gekommen wären. await seite.evaluate(() => { document.documentElement.setAttribute('data-thema', 'dunkel'); localStorage.setItem('lernbar-thema', 'dunkel'); }); await seite.emulateMedia({ media: 'print' }); await seite.waitForTimeout(200); const druckfarben = await seite.evaluate(() => { const stil = getComputedStyle(document.body); const wurzel = getComputedStyle(document.documentElement); return { grund: stil.backgroundColor, text: stil.color, flaeche: wurzel.getPropertyValue('--flaeche').trim() }; }); pruefe( druckfarben.grund === 'rgb(255, 255, 255)', 'Im Druck ist der Grund weiß, auch bei eingestellter dunkler Fassung', druckfarben.grund, ); pruefe(druckfarben.text === 'rgb(0, 0, 0)', 'Der Text ist schwarz', druckfarben.text); pruefe( druckfarben.flaeche.toLowerCase() === '#f2f2f2', 'Auch die Flächen sind hell', druckfarben.flaeche, ); const versteckt = await seite.evaluate(() => { const kopf = document.querySelector('.kopf'); return kopf ? getComputedStyle(kopf).display : 'keine'; }); pruefe(versteckt === 'none', 'Die Navigation wird nicht mitgedruckt', versteckt); await seite.emulateMedia({ media: null }); await browser.close(); console.log(`\n${fehler === 0 ? '✓ Baustein 10 steht.' : `✗ ${fehler} Prüfung(en) fehlgeschlagen.`}`); process.exit(fehler === 0 ? 0 : 1);