Das Protokoll war bisher nur in der Datenbank lesbar. Die Ansicht zeigt es jeder Rolle mit lesbaren Aktionstexten, aufklappbarem Diff, Details und Hashes; ein Test haelt fest, dass jede Aktion im Code einen Text hat. Geblaettert wird ueber die ID statt OFFSET, weil das Protokoll waehrend des Lesens waechst. Die Kettenpruefung liest jede Zeile und laeuft deshalb nur auf Knopfdruck, mit 5 Minuten Zwischenspeicher; ein Bruch wird selbst protokolliert. Der CSV-Export streamt in Stapeln, entschaerft Formeln und steht selbst im Protokoll. protokollieren() reicht jeden Eintrag nach dem Schreiben an Syslog weiter, ohne Details und Diff. Kein Weg aendert oder loescht Eintraege; ein struktureller Test sichert das ab, zusaetzlich zum Trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.4 KiB
TypeScript
134 lines
5.4 KiB
TypeScript
/**
|
||
* Screenshots der Oberfläche für Anleitung und Design-Abnahme (#002).
|
||
*
|
||
* pnpm build && pnpm start # erstes Terminal
|
||
* pnpm exec tsx scripts/screenshots.ts # zweites Terminal
|
||
*
|
||
* Umgebung:
|
||
* SSHKV_URL Basis-URL (Standard http://localhost:3400)
|
||
* SSHKV_BILDER Zielordner (Standard docs/anleitung/bilder)
|
||
* SSHKV_SEITEN Kommagetrennte Auswahl der Seitennamen (Standard: alle)
|
||
*
|
||
* Prüft nebenbei, dass keine Seite horizontal scrollt (Design-Vorgabe ab 390 px), und
|
||
* endet dann mit Fehlercode – so taugt das Skript auch als schneller Layout-Test.
|
||
*/
|
||
import { mkdir } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { chromium } from "@playwright/test";
|
||
|
||
const BASIS = (process.env["SSHKV_URL"] ?? "http://localhost:3400").replace(/\/$/, "");
|
||
const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/bilder");
|
||
|
||
/**
|
||
* `oeffentlich`: ohne Anmeldung aufnehmen. Alle anderen nach Anmeldung ueber den Mock-OIDC (#004).
|
||
* `folgen`: Seiten mit wechselnder ID (#008) – erst `pfad` laden, dann dem ersten Link folgen,
|
||
* der zum Selektor passt (z. B. der erste Host der Liste, bei SSHKV_SEED_DEMO=1 ein Demo-Host).
|
||
*/
|
||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||
{ name: "uebersicht", pfad: "/" },
|
||
{ name: "schluessel", pfad: "/schluessel" },
|
||
{ name: "schluessel-neu", pfad: "/schluessel/neu" },
|
||
// Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012).
|
||
{
|
||
name: "schluessel-detail",
|
||
pfad: "/schluessel?q=wartung",
|
||
folgen: 'main table a[href^="/schluessel/"]',
|
||
},
|
||
{ name: "personen", pfad: "/personen" },
|
||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||
{ name: "hosts", pfad: "/hosts" },
|
||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
|
||
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
|
||
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
|
||
{ name: "vorlagen", pfad: "/vorlagen" },
|
||
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||
{
|
||
name: "vorlage-bearbeiten",
|
||
pfad: "/vorlagen",
|
||
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||
},
|
||
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
|
||
{ name: "protokoll", pfad: "/protokoll" },
|
||
];
|
||
|
||
const ANSICHTEN = [
|
||
{ name: "desktop", width: 1440, height: 900 },
|
||
{ name: "mobil", width: 390, height: 844 },
|
||
];
|
||
|
||
async function main() {
|
||
const auswahl = process.env["SSHKV_SEITEN"]?.split(",").map((s) => s.trim());
|
||
const seiten = auswahl ? SEITEN.filter((s) => auswahl.includes(s.name)) : SEITEN;
|
||
await mkdir(ZIEL, { recursive: true });
|
||
|
||
const browser = await chromium.launch();
|
||
const fehler: string[] = [];
|
||
try {
|
||
for (const ansicht of ANSICHTEN) {
|
||
const kontext = await browser.newContext({
|
||
viewport: { width: ansicht.width, height: ansicht.height },
|
||
deviceScaleFactor: 1,
|
||
locale: "de-DE",
|
||
timezoneId: "Europe/Berlin",
|
||
colorScheme: "dark",
|
||
});
|
||
const seite = await kontext.newPage();
|
||
let angemeldet = false;
|
||
for (const s of seiten) {
|
||
if (!s.oeffentlich && !angemeldet) {
|
||
// Anmeldung ueber den Mock-OIDC aus docker-compose.test.yml, Rolle Admin.
|
||
await seite.goto(`${BASIS}/anmelden`);
|
||
await seite
|
||
.getByRole("link", { name: /anmelden/i })
|
||
.first()
|
||
.click();
|
||
await seite.locator('button[data-rolle="admin"]').click();
|
||
await seite.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||
await seite.waitForURL(`${BASIS}/`);
|
||
angemeldet = true;
|
||
}
|
||
let antwort = await seite.goto(`${BASIS}${s.pfad}`, { waitUntil: "networkidle" });
|
||
if (s.folgen && antwort?.ok()) {
|
||
const links = seite.locator(s.folgen);
|
||
const ziel = (await links.count()) > 0 ? await links.first().getAttribute("href") : null;
|
||
if (!ziel) {
|
||
fehler.push(`${s.pfad}: kein Link für „${s.name}“ (${s.folgen}) – Demo-Daten geseedet?`);
|
||
continue;
|
||
}
|
||
antwort = await seite.goto(`${BASIS}${ziel}`, { waitUntil: "networkidle" });
|
||
}
|
||
if (!antwort?.ok()) {
|
||
fehler.push(`${s.pfad}: HTTP ${antwort?.status() ?? "keine Antwort"}`);
|
||
continue;
|
||
}
|
||
// Erst nach dem Laden der selbst gehosteten Schriften fotografieren, sonst
|
||
// stehen Fallback-Schriften im Bild.
|
||
await seite.evaluate(() => document.fonts.ready);
|
||
|
||
const ueberlauf = await seite.evaluate(
|
||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||
);
|
||
if (ueberlauf > 0) {
|
||
fehler.push(`${s.pfad} @ ${ansicht.width}px: ${ueberlauf}px horizontaler Überlauf`);
|
||
}
|
||
|
||
const datei = join(ZIEL, `${s.name}-${ansicht.name}.png`);
|
||
await seite.screenshot({ path: datei, fullPage: true });
|
||
console.log(`✓ ${datei}`);
|
||
}
|
||
await kontext.close();
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
|
||
if (fehler.length > 0) {
|
||
console.error(`\n${fehler.length} Problem(e):\n- ${fehler.join("\n- ")}`);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
await main();
|