import { chromium } from "playwright"; import fs from "node:fs"; import { createServer } from "node:http"; const OUT = "docs/screenshots"; const BASE = "http://localhost:3011"; fs.mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch(); const errs = []; async function session(width, height, theme) { const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2, colorScheme: theme === "light" ? "light" : "dark", locale: "de-DE", timezoneId: "Europe/Berlin", }); await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme); // Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren. await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" }); const page = await ctx.newPage(); await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {}); page.on("pageerror", (e) => errs.push(String(e).slice(0, 160))); return { ctx, page }; } async function login(page) { await page.goto(`${BASE}/anmelden`); await page.fill("#email", "demo@flimmer.local"); await page.fill("#password", "flimmer-demo-2026"); await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]); } /** Screenshot eines einzelnen Abschnitts statt der ganzen Seite. */ async function ausschnitt(page, name, ueberschrift) { // Einblendungen wegklicken — sie legen sich sonst über den Inhalt. const schliessen = page.locator("[aria-live] button"); for (let i = await schliessen.count(); i > 0; i -= 1) { await schliessen.first().click().catch(() => {}); await page.waitForTimeout(150); } await page.waitForTimeout(400); const bereich = page.locator("section", { hasText: ueberschrift }).first(); await bereich.screenshot({ path: `${OUT}/${name}.png` }); console.log(" ", name); } /** * Ein Jellyfin, das gerade genug kann: anmelden, Systeminfo, Bestand. * Die TMDB-Kennungen zeigen auf Titel aus dem Seed, damit der Bericht echte * Zuordnungen zeigt. Alles auf ungesehen — der Screenshot soll den Bestand * zeigen, nicht die Sichtungen der Beispieldaten verbiegen. */ async function starteJellyfinNachbau() { const items = [ { Id: "jf-1", Name: "Die Stille zwischen den Bildern", Type: "Movie", ProviderIds: { Tmdb: "9100013" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } }, { Id: "jf-2", Name: "Die Stille zwischen den Bildern II", Type: "Movie", ProviderIds: { Tmdb: "9100014" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } }, { Id: "jf-3", Name: "Die Vermessung des Feierabends", Type: "Movie", ProviderIds: { Tmdb: "9100017" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } }, { Id: "jf-4", Name: "Urlaubsvideo 2019", Type: "Movie", ProviderIds: {}, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } }, ]; const server = createServer((req, res) => { const url = new URL(req.url, "http://x"); const json = (body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; if (url.pathname === "/Users/AuthenticateByName") { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => json({ AccessToken: "screenshot-token", User: { Id: "jf-user", Name: "florian" } }), ); return; } if (url.pathname === "/System/Info") return json({ ServerName: "Heimserver", Version: "10.10.3" }); if (url.pathname === "/Items") return json({ Items: items, TotalRecordCount: items.length }); res.writeHead(404); res.end(); }); await new Promise((fertig) => server.listen(0, "127.0.0.1", fertig)); const port = server.address().port; return { url: `http://127.0.0.1:${port}`, close: () => new Promise((fertig) => server.close(fertig)), }; } async function shot(page, name, url, opts = {}) { await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" }); await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {}); await page.waitForTimeout(opts.wait ?? 900); if (opts.before) await opts.before(page); await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) }); console.log(" ", name); } // --- Anmeldung (ohne Sitzung) --- { const { ctx, page } = await session(1280, 800, "dark"); await shot(page, "01-anmeldung", "/anmelden"); await ctx.close(); } // --- Angemeldet, dunkel --- { const { ctx, page } = await session(1400, 900, "dark"); await login(page); await shot(page, "02-uebersicht", "/", { full: true }); await shot(page, "03-suche", "/suche?q=sternenpfad"); await shot(page, "05-bibliothek", "/bibliothek"); await shot(page, "09-kalender", "/kalender"); await shot(page, "10-statistik", "/statistik", { full: true }); await shot(page, "11-einstellungen", "/einstellungen"); await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen"); await shot(page, "13-api", "/einstellungen/api"); await shot(page, "14-daten", "/einstellungen/daten"); await shot(page, "15-verwaltung", "/verwaltung", { full: true }); await shot(page, "16-papierkorb", "/papierkorb"); // Detailseite und Universum brauchen IDs aus der Bibliothek await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" }); const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href"); if (titleHref) await shot(page, "04-serie", titleHref, { full: true }); await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" }); await shot(page, "06-universen", "/universen"); const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href"); if (uniHref) await shot(page, "07-universum", uniHref, { full: true }); await ctx.close(); } // --- Zugang: Passkeys und Medienserver --- // // Beide brauchen Gegenstellen: einen Authenticator und einen Jellyfin-Server. // Statt echte Geräte vorauszusetzen, stellt das Skript sie selbst bereit — // der virtuelle Authenticator kommt vom Browser, der Medienserver ist ein // paar Zeilen HTTP hier drin. Beide Spuren werden am Ende wieder entfernt. { const jellyfin = await starteJellyfinNachbau(); const { ctx, page } = await session(1400, 1000, "dark"); const cdp = await ctx.newCDPSession(page); await cdp.send("WebAuthn.enable"); await cdp.send("WebAuthn.addVirtualAuthenticator", { options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, }, }); await login(page); await page.goto(`${BASE}/einstellungen`, { waitUntil: "networkidle" }); // Passkey anlegen, damit die Liste nicht leer ist. await page.fill("input[aria-label='Name für den neuen Passkey']", "Mein Notebook"); await page.getByRole("button", { name: "Passkey anlegen" }).click(); await page.waitForTimeout(2500); await ausschnitt(page, "21-passkeys", "Passkeys"); // Jellyfin verbinden und einmal abgleichen. const medien = page.locator("section", { hasText: "Medienserver" }); await page.fill("input[placeholder='http://jellyfin.fritz.box:8096']", jellyfin.url); await medien.locator("input").nth(1).fill("florian"); await medien.locator("input").nth(2).fill("geheim"); await page.getByRole("button", { name: "Verbinden" }).click(); await page.waitForTimeout(2500); await page.getByRole("button", { name: "Jetzt abgleichen" }).click(); await page.waitForTimeout(3500); await ausschnitt(page, "22-medienserver", "Medienserver"); // Spuren beseitigen: erst die Verbindung, dann den Passkey. await page.getByRole("button", { name: "Entfernen" }).first().click(); await page.waitForTimeout(1500); await page.reload({ waitUntil: "networkidle" }); const passkeyWeg = page.locator("section", { hasText: "Passkeys" }).getByRole("button", { name: "Entfernen" }); if (await passkeyWeg.count()) { await passkeyWeg.first().click(); await page.waitForTimeout(1500); } await ctx.close(); await jellyfin.close(); } // --- Hell --- { const { ctx, page } = await session(1400, 900, "light"); await login(page); await shot(page, "08-hell", "/bibliothek"); await ctx.close(); } // --- Mobil --- { const { ctx, page } = await session(430, 900, "dark"); await login(page); await shot(page, "17-mobil-uebersicht", "/"); await shot(page, "18-mobil-serie", "/bibliothek"); await ctx.close(); } console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine"); await browser.close();