CI / pruefen (pull_request) Canceled after 0s
Passkeys und die Jellyfin-Anbindung als groessere Bausteine, dazu vier Nacharbeiten aus dem Betrieb. Changelog, Release-Notiz, DEVLOG und die bebilderte Anleitung nachgezogen. Die Anleitung hat zwei neue Abschnitte samt Screenshots: „Anmelden per Passkey" und ein Bild im Jellyfin-Abschnitt. Beide Aufnahmen brauchen Gegenstellen — einen Authenticator und einen Medienserver. Damit `npm run screenshots` reproduzierbar bleibt, stellt das Skript sie jetzt selbst bereit: der virtuelle Authenticator kommt ueber CDP vom Browser, der Jellyfin-Nachbau sind ein paar Zeilen HTTP im Skript. Beide Spuren raeumt es nach der Aufnahme ueber die Oberflaeche wieder weg. Der Merkposten zum Entwicklungsmodus in CLAUDE.md war zu absolut: dass unter Playwright nichts hydratisiert, stimmt nicht durchgehend — im Screenshot-Lauf gegen denselben Dev-Server funktionierten die Klicks. Der Eintrag beschreibt jetzt das Beobachtete statt einer Regel, die nicht traegt, und nennt die Pruefung, mit der sich der Zustand feststellen laesst. Dazu neu: APP_URL muss zum aufgerufenen Port passen, sonst scheitern Passkeys am Ursprungsabgleich. Alle Screenshots neu aufgenommen — die Einstellungsseite hatte sich durch die neuen Abschnitte ohnehin geaendert.
221 lines
8.5 KiB
JavaScript
221 lines
8.5 KiB
JavaScript
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();
|