CI / pruefen (pull_request) Canceled after 0s
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene Sichtung soll kein Abzeichen kosten. Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest. Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst, und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie "Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag, greift der generische Name. Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche Abzeichen zu geben. Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde "03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt. Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt. Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch Farbe, sondern auch im Text. Refs #15
236 lines
9.1 KiB
JavaScript
236 lines
9.1 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);
|
|
|
|
// Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des
|
|
// Ausschnitts. Fürs Bild kurz aufheben.
|
|
await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {});
|
|
await page.waitForTimeout(200);
|
|
|
|
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();
|
|
}
|
|
|
|
// --- Errungenschaften ---
|
|
{
|
|
const { ctx, page } = await session(1400, 1200, "dark");
|
|
await login(page);
|
|
await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
await ausschnitt(page, "23-errungenschaften", "Errungenschaften");
|
|
await ctx.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();
|