Version 0.8.0
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.
This commit is contained in:
Florian Wilde
2026-09-02 10:35:43 +02:00
parent 2deb7e1aa5
commit 0f9ad6e29d
27 changed files with 455 additions and 10 deletions
+121
View File
@@ -1,5 +1,6 @@
import { chromium } from "playwright";
import fs from "node:fs";
import { createServer } from "node:http";
const OUT = "docs/screenshots";
const BASE = "http://localhost:3011";
@@ -32,6 +33,68 @@ async function login(page) {
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(() => {});
@@ -78,6 +141,64 @@ async function shot(page, name, url, opts = {}) {
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");