515 lines
19 KiB
TypeScript
515 lines
19 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* Erzeugt die Screenshots reproduzierbar neu – fuer die Meilenstein-Belege unter
|
||
* docs/screenshots/<meilenstein>/ und fuer die Anleitung unter docs/anleitung/bilder/.
|
||
*
|
||
* Bilder werden nie von Hand nachbearbeitet. Aendert sich die Oberflaeche, laeuft
|
||
* dieses Skript erneut.
|
||
*
|
||
* pnpm docs:screenshots alle Seiten nach docs/anleitung/bilder/
|
||
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
|
||
* pnpm docs:screenshots --nur uebersicht nur diese Seite
|
||
*
|
||
* Voraussetzung: die App laeuft als **gebauter Stand** (pnpm build && pnpm start).
|
||
* Adresse ueber E2E_APP_URL, Standard http://127.0.0.1:3000.
|
||
*
|
||
* Ausdruecklich nicht gegen `pnpm dev`: dort haengt das Entwickler-Overlay im
|
||
* Bild, und in dieser Umgebung hydriert der Entwicklungsmodus gar nicht — ein
|
||
* Bild sieht dann richtig aus, zeigt aber eine Seite, auf der kein Knopf etwas
|
||
* tut. Siehe CLAUDE.md, Abschnitt „Oberflaeche pruefen“.
|
||
*/
|
||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { chromium, type Browser, type Cookie, type Locator, type Page } from "@playwright/test";
|
||
|
||
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||
|
||
const ANSICHTEN = [
|
||
{ name: "desktop", breite: 1440, hoehe: 900 },
|
||
{ name: "handy", breite: 390, hoehe: 844 },
|
||
] as const;
|
||
|
||
type Seite = {
|
||
readonly name: string;
|
||
readonly pfad: string;
|
||
/** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */
|
||
readonly ohneAnmeldung?: boolean;
|
||
/**
|
||
* Nur aufnehmen, wenn der Betriebsmodus passt. Der Modus steht in der
|
||
* Datenbank; wer beide Seiten braucht, laesst das Skript zweimal laufen und
|
||
* stellt dazwischen um.
|
||
*/
|
||
readonly nurModus?: "INTERN" | "DIENSTLEISTUNG";
|
||
/** Vor der Aufnahme ausfuehren, etwa ein Aufklappen. */
|
||
readonly vorbereiten?: (tab: Page) => Promise<void>;
|
||
/**
|
||
* Hell aufnehmen statt dunkel. Fuer Druckseiten: ein Etikett ist immer hell,
|
||
* und ein Bild im dunklen Farbschema zeigte etwas, das so nie aus einem
|
||
* Drucker kommt (#014).
|
||
*/
|
||
readonly hell?: boolean;
|
||
/**
|
||
* Nur diesen Ausschnitt aufnehmen statt der ganzen Seite. Fuer Bilder, die
|
||
* ein einzelnes Element zeigen sollen — eine ganze Seite waere dasselbe Bild
|
||
* wie nebenan, nur groesser.
|
||
*/
|
||
readonly ausschnitt?: (tab: Page) => Locator;
|
||
/**
|
||
* Bereiche, die im Bild hervorgehoben werden – Rahmen in der Signal-Lime mit
|
||
* einer Nummer, auf die der Text der Anleitung sich beziehen kann (#018).
|
||
* Ohne Angabe entsteht genau dasselbe Bild wie vorher.
|
||
*/
|
||
readonly markierungen?: readonly Markierung[];
|
||
};
|
||
|
||
/**
|
||
* Eine Markierung im Bild. Die Nummer kommt aus der Reihenfolge – sie im Text
|
||
* der Anleitung zu nennen ist der ganze Zweck („(1) zeigt den Fortschritt“).
|
||
*/
|
||
type Markierung = {
|
||
readonly ziel: (tab: Page) => Locator;
|
||
};
|
||
|
||
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||
const SEITEN: readonly Seite[] = [
|
||
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
|
||
{ name: "uebersicht", pfad: "/" },
|
||
{ name: "admin-einstellungen", pfad: "/admin" },
|
||
{
|
||
name: "admin-moduswechsel",
|
||
pfad: "/admin",
|
||
nurModus: "INTERN",
|
||
vorbereiten: async (tab) => {
|
||
// Gezielt den Wechsel-Dialog aufklappen. `getByRole("group").first()`
|
||
// traf bei 390 px das Navigationsmenue, weil das dort das erste details ist.
|
||
await tab.locator("summary", { hasText: "wechseln" }).click();
|
||
await tab.waitForTimeout(150);
|
||
},
|
||
},
|
||
{
|
||
// Mit mehr als einem nicht archivierten Kunden ist der Rueckweg gesperrt.
|
||
// Genau das soll die Anleitung zeigen.
|
||
name: "admin-moduswechsel-abgelehnt",
|
||
pfad: "/admin",
|
||
nurModus: "DIENSTLEISTUNG",
|
||
},
|
||
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
||
{ name: "kunden", pfad: "/kunden", nurModus: "DIENSTLEISTUNG" },
|
||
{ name: "vorlagen-liste", pfad: "/vorlagen" },
|
||
/*
|
||
* Vorlagenseite und Editor haengen an Kennungen, die je Datenbank anders sind.
|
||
* Statt sie zu erraten, geht das Skript den Weg, den auch ein Redakteur geht:
|
||
* von der Liste aus klicken. Bricht der Weg, bricht der Screenshot — und das
|
||
* ist die richtige Antwort, denn dann ist die Oberflaeche kaputt.
|
||
*/
|
||
{
|
||
name: "vorlagen-seite",
|
||
pfad: "/vorlagen",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /PC-Neuinstallation/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/vorlagen\/[^/]+$/);
|
||
},
|
||
},
|
||
{
|
||
name: "vorlagen-editor",
|
||
pfad: "/vorlagen",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /Entwurf öffnen/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/vorlagen\/version\/[^/]+$/);
|
||
},
|
||
},
|
||
{ name: "mitarbeiter-liste", pfad: "/mitarbeiter" },
|
||
{
|
||
name: "mitarbeiter-seite",
|
||
pfad: "/mitarbeiter",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /Maria Huber/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
|
||
},
|
||
},
|
||
{
|
||
// Die gesperrten Stammdaten einer Person aus dem Verzeichnis – der Fall,
|
||
// den man sonst erst nach dem ersten LDAP-Abgleich zu sehen bekaeme.
|
||
name: "mitarbeiter-verzeichnis",
|
||
pfad: "/mitarbeiter",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /Katrin Winter/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/mitarbeiter\/[^/]+$/);
|
||
},
|
||
},
|
||
{ name: "geraete-liste", pfad: "/geraete" },
|
||
{ name: "laeufe-liste", pfad: "/laeufe" },
|
||
{
|
||
name: "lauf-starten",
|
||
pfad: "/laeufe/starten",
|
||
// (1) Vorlage, (2) Geraet – die zwei Felder, ueber die das Kapitel „Lauf
|
||
// starten“ redet; alles darunter ist freiwillig.
|
||
markierungen: [
|
||
{ ziel: (tab) => tab.locator('select[name="vorlagenVersionId"]') },
|
||
{ ziel: (tab) => tab.locator('select[name="geraetId"]') },
|
||
],
|
||
},
|
||
{
|
||
name: "lauf-abarbeiten",
|
||
pfad: "/laeufe",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /NB-001/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||
},
|
||
// (1) Fortschritt, (2) Zustandspillen des ersten Schritts, (3) Kommentare.
|
||
markierungen: [
|
||
{ ziel: (tab) => tab.getByRole("progressbar") },
|
||
{
|
||
ziel: (tab) =>
|
||
tab
|
||
.getByRole("listitem")
|
||
.filter({ has: tab.getByRole("button", { name: "erledigt", exact: true }) })
|
||
.first()
|
||
.getByRole("button", { name: "erledigt", exact: true })
|
||
// Nicht die Pille allein, sondern die Zeile mit allen drei Zustaenden.
|
||
.locator(".."),
|
||
},
|
||
{
|
||
ziel: (tab) =>
|
||
tab.locator("section").filter({ has: tab.getByRole("heading", { name: "Kommentare zum Lauf" }) }),
|
||
},
|
||
],
|
||
},
|
||
{
|
||
// Der Befehlsschritt, aufgeklappt und in den Blick gescrollt: am Handy
|
||
// stuende er sonst weit unterhalb des Bildrands.
|
||
name: "lauf-befehlsschritt",
|
||
pfad: "/laeufe",
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /NB-001/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||
const schritt = tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" });
|
||
await schritt.scrollIntoViewIfNeeded();
|
||
await tab.waitForTimeout(150);
|
||
},
|
||
ausschnitt: (tab) => tab.getByRole("listitem").filter({ hasText: "Rechner umbenannt" }),
|
||
},
|
||
{ name: "abteilungen", pfad: "/admin/abteilungen" },
|
||
{ name: "scannen", pfad: "/scannen" },
|
||
{ name: "tags", pfad: "/tags" },
|
||
{
|
||
name: "geraeteseite",
|
||
pfad: "/geraete",
|
||
vorbereiten: async (tab) => {
|
||
await tab.getByRole("link", { name: "NB-001" }).first().click();
|
||
await tab.waitForURL(/\/geraete\/[^/]+$/);
|
||
},
|
||
// (1) der Weg zum QR-Code, (2) was schon zugewiesen ist,
|
||
// (3) der Weg zu einer weiteren Vorlage.
|
||
markierungen: [
|
||
// Der Code selbst steht in einem zugeklappten `details` und waere im Bild
|
||
// nicht zu sehen – markiert wird deshalb der Griff, der ihn aufklappt.
|
||
{ ziel: (tab) => tab.locator("summary", { hasText: "QR-Code anzeigen" }) },
|
||
{ ziel: (tab) => tab.getByRole("heading", { name: "Zugewiesen" }).locator("..") },
|
||
{ ziel: (tab) => tab.getByRole("heading", { name: "Vorlage zuweisen" }).locator("..") },
|
||
],
|
||
},
|
||
{
|
||
/*
|
||
* Das Registrierformular gibt es nur zu einem Tag ohne Zuordnung. Welcher
|
||
* das ist, weiss nur die Datenbank — also den Weg gehen, den auch ein
|
||
* Techniker geht: von der Tag-Liste aus.
|
||
*/
|
||
name: "tag-registrieren",
|
||
pfad: "/tags",
|
||
vorbereiten: async (tab) => {
|
||
await tab.getByRole("link", { name: /^LZ-/ }).first().click();
|
||
await tab.waitForURL(/\/t\/LZ-/);
|
||
},
|
||
},
|
||
{ name: "archiv", pfad: "/archiv?suche=Inventar" },
|
||
{ name: "verzeichnis", pfad: "/admin/verzeichnis", nurModus: "INTERN" },
|
||
{
|
||
/*
|
||
* Der Verbindungstest zeigt seinen Wert erst mit Ergebnis: die Tabelle der
|
||
* gemappten Felder. Also wird der Knopf gedrueckt und gewartet, bis der
|
||
* Server geantwortet hat — ohne Container steht dort eine Fehlermeldung,
|
||
* und auch die ist ein ehrliches Bild.
|
||
*/
|
||
name: "verbindungstest",
|
||
pfad: "/admin/verzeichnis",
|
||
nurModus: "INTERN",
|
||
vorbereiten: async (tab) => {
|
||
const knopf = tab.getByRole("button", { name: "Verbindung testen" }).first();
|
||
if ((await knopf.count()) === 0) return;
|
||
await knopf.click();
|
||
await tab.waitForTimeout(4000);
|
||
},
|
||
},
|
||
{
|
||
/*
|
||
* Das Abgleich-Log braucht einen Lauf, damit ueberhaupt etwas darin steht.
|
||
* Also erst „Jetzt abgleichen“ druecken und dann aufklappen — ob der Lauf
|
||
* gelingt, haengt am Testcontainer, und beide Ausgaenge sind ein ehrliches
|
||
* Bild: die Zeile zeigt Zahlen oder den Grund.
|
||
*/
|
||
name: "abgleich-log",
|
||
pfad: "/admin/verzeichnis",
|
||
nurModus: "INTERN",
|
||
vorbereiten: async (tab) => {
|
||
const knopf = tab.getByRole("button", { name: "Jetzt abgleichen" }).first();
|
||
if ((await knopf.count()) > 0) {
|
||
await knopf.click();
|
||
await tab.waitForTimeout(5000);
|
||
await tab.reload({ waitUntil: "load" });
|
||
}
|
||
const aufklappen = tab.locator("summary", { hasText: "Abgleich-Log" }).first();
|
||
if ((await aufklappen.count()) === 0) return;
|
||
await aufklappen.click();
|
||
await tab.waitForTimeout(150);
|
||
},
|
||
},
|
||
{
|
||
name: "personen-import",
|
||
pfad: "/mitarbeiter",
|
||
vorbereiten: async (tab) => {
|
||
await tab.locator("summary", { hasText: "CSV" }).first().click();
|
||
await tab.waitForTimeout(150);
|
||
},
|
||
},
|
||
{ name: "abteilungen-vorschlaege", pfad: "/admin/abteilungen" },
|
||
{
|
||
/*
|
||
* Die Druckansicht braucht einen Lauf, und welcher das ist, weiss nur die
|
||
* Datenbank. Also den Weg gehen, den auch ein Mensch geht: von der Laufliste
|
||
* aus zum Lauf und von dort auf „Drucken“.
|
||
*/
|
||
name: "druckansicht",
|
||
pfad: "/laeufe",
|
||
hell: true,
|
||
vorbereiten: async (tab) => {
|
||
await tab
|
||
.getByRole("link", { name: /NB-001/ })
|
||
.first()
|
||
.click();
|
||
await tab.waitForURL(/\/laeufe\/[^/]+$/);
|
||
await tab.getByRole("link", { name: "Drucken" }).click();
|
||
await tab.waitForURL(/\/drucken\/lauf\//);
|
||
},
|
||
},
|
||
{ name: "etikett-62x29", pfad: "/drucken/etiketten?layout=etikett", hell: true },
|
||
{ name: "etiketten-a4", pfad: "/drucken/etiketten?layout=bogen", hell: true },
|
||
];
|
||
|
||
function argument(name: string): string | undefined {
|
||
const index = process.argv.indexOf(`--${name}`);
|
||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||
}
|
||
|
||
async function warteAufApp(): Promise<void> {
|
||
for (let versuch = 1; versuch <= 30; versuch += 1) {
|
||
try {
|
||
const antwort = await fetch(APP_URL, { signal: AbortSignal.timeout(2000) });
|
||
if (antwort.ok) return;
|
||
} catch {
|
||
// noch nicht da
|
||
}
|
||
await new Promise((f) => setTimeout(f, 1000));
|
||
}
|
||
throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`);
|
||
}
|
||
|
||
/**
|
||
* Meldet sich mit dem Seed-Administrator an und gibt den Sitzungs-Cookie zurueck.
|
||
*
|
||
* Geht ueber das echte Formular statt ueber einen gesetzten Cookie: so faellt
|
||
* sofort auf, wenn die Anmeldung selbst kaputt ist.
|
||
*/
|
||
async function anmelden(browser: Browser): Promise<Cookie[]> {
|
||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||
if (!email || !passwort) {
|
||
throw new Error(
|
||
"SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD werden gebraucht, um die geschuetzten Seiten " +
|
||
"aufzunehmen. Siehe .env.example.",
|
||
);
|
||
}
|
||
|
||
const kontext = await browser.newContext({ baseURL: APP_URL });
|
||
const tab = await kontext.newPage();
|
||
await tab.goto("/anmelden", { waitUntil: "load" });
|
||
await tab.fill('input[name="email"]', email);
|
||
await tab.fill('input[name="passwort"]', passwort);
|
||
await Promise.all([tab.waitForURL("**/"), tab.click('button[type="submit"]')]);
|
||
|
||
const kekse = await kontext.cookies();
|
||
if (!kekse.some((keks) => keks.name === "lz_sitzung")) {
|
||
throw new Error("Die Anmeldung hat kein Sitzungscookie gesetzt.");
|
||
}
|
||
await kontext.close();
|
||
return kekse;
|
||
}
|
||
|
||
/**
|
||
* Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht.
|
||
*
|
||
* Geschrieben wird nur, wenn sich die Bytes wirklich unterscheiden. Vorher legte
|
||
* jeder Screenshot-Lauf zwei geaenderte PNG-Dateien in den Arbeitsbaum, obwohl das
|
||
* Bild dasselbe war — und `git status` zeigte Aenderungen, die niemand gemacht
|
||
* hatte (Reviewbefund zu #016).
|
||
*/
|
||
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
||
for (const kante of [192, 512]) {
|
||
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
|
||
await seite.goto(svg, { waitUntil: "load" });
|
||
const neu = await seite.screenshot({ omitBackground: false });
|
||
await seite.close();
|
||
|
||
const datei = join("public", `icon-${kante}.png`);
|
||
const alt = await readFile(datei).catch(() => undefined);
|
||
if (alt && alt.equals(neu)) {
|
||
console.log(` ${datei} (unverändert)`);
|
||
continue;
|
||
}
|
||
await writeFile(datei, neu);
|
||
console.log(` ${datei}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Legt die Markierungen als Overlay in die Seite, bevor das Bild entsteht.
|
||
*
|
||
* Gezeichnet wird im Browser und in Dokumentkoordinaten (`getBoundingClientRect`
|
||
* plus `scrollX`/`scrollY`), damit der Rahmen auch auf einem `fullPage`-Bild dort
|
||
* sitzt, wo er hingehoert. Nachbearbeitet wird kein Bild – wer die Oberflaeche
|
||
* aendert, laesst dieses Skript erneut laufen, und die Rahmen wandern mit.
|
||
*
|
||
* Findet eine Markierung ihr Ziel nicht, bricht der Lauf ab: ein Bild mit
|
||
* fehlender „(2)“ macht den Text der Anleitung falsch.
|
||
*/
|
||
async function markierungenEinspritzen(
|
||
tab: Page,
|
||
seitenname: string,
|
||
markierungen: readonly Markierung[],
|
||
): Promise<void> {
|
||
for (const [stelle, markierung] of markierungen.entries()) {
|
||
const ziel = markierung.ziel(tab).first();
|
||
if ((await ziel.count()) === 0) {
|
||
throw new Error(`Markierung ${stelle + 1} von "${seitenname}" findet ihr Ziel nicht.`);
|
||
}
|
||
await ziel.evaluate((element, nummer) => {
|
||
const kasten = element.getBoundingClientRect();
|
||
const links = kasten.left + window.scrollX;
|
||
const oben = kasten.top + window.scrollY;
|
||
const rahmen = document.createElement("div");
|
||
Object.assign(rahmen.style, {
|
||
position: "absolute",
|
||
left: `${links - 5}px`,
|
||
top: `${oben - 5}px`,
|
||
width: `${kasten.width + 10}px`,
|
||
height: `${kasten.height + 10}px`,
|
||
border: "2px solid #C6F135",
|
||
borderRadius: "14px",
|
||
pointerEvents: "none",
|
||
zIndex: "2147483647",
|
||
});
|
||
const marke = document.createElement("div");
|
||
marke.textContent = String(nummer);
|
||
Object.assign(marke.style, {
|
||
position: "absolute",
|
||
// Am linken Rand rutscht die Nummer nach innen, sonst schnitte sie das Bild ab.
|
||
left: links < 24 ? "4px" : "-14px",
|
||
top: "-14px",
|
||
width: "26px",
|
||
height: "26px",
|
||
borderRadius: "999px",
|
||
background: "#C6F135",
|
||
color: "#0B0D10",
|
||
font: "700 15px/26px ui-sans-serif, system-ui, sans-serif",
|
||
textAlign: "center",
|
||
});
|
||
rahmen.append(marke);
|
||
document.body.append(rahmen);
|
||
}, stelle + 1);
|
||
}
|
||
}
|
||
|
||
const zielMeilenstein = argument("ziel");
|
||
const nur = argument("nur");
|
||
/** Welcher Betriebsmodus gerade eingestellt ist – steuert, welche Seiten passen. */
|
||
const modus = (argument("modus") ?? "INTERN") as "INTERN" | "DIENSTLEISTUNG";
|
||
const ziel = zielMeilenstein
|
||
? join("docs", "screenshots", zielMeilenstein)
|
||
: join("docs", "anleitung", "bilder");
|
||
|
||
await warteAufApp();
|
||
await mkdir(ziel, { recursive: true });
|
||
|
||
const browser = await chromium.launch();
|
||
try {
|
||
await kachelnErzeugen(browser);
|
||
|
||
const auswahl = SEITEN.filter(
|
||
(s) => (!nur || s.name === nur) && (s.nurModus === undefined || s.nurModus === modus),
|
||
);
|
||
const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : [];
|
||
|
||
for (const seite of auswahl) {
|
||
for (const ansicht of ANSICHTEN) {
|
||
const kontext = await browser.newContext({
|
||
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
||
colorScheme: seite.hell ? "light" : "dark",
|
||
locale: "de-DE",
|
||
timezoneId: "Europe/Berlin",
|
||
deviceScaleFactor: 2,
|
||
});
|
||
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
|
||
const tab = await kontext.newPage();
|
||
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" });
|
||
if (seite.vorbereiten) await seite.vorbereiten(tab);
|
||
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||
await tab.evaluate(() => document.fonts.ready);
|
||
await tab.waitForLoadState("domcontentloaded");
|
||
if (seite.markierungen) await markierungenEinspritzen(tab, seite.name, seite.markierungen);
|
||
|
||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||
if (seite.ausschnitt) {
|
||
await seite.ausschnitt(tab).screenshot({ path: datei });
|
||
} else {
|
||
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||
}
|
||
|
||
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
||
if (ansicht.name === "handy") {
|
||
const masse = await tab.evaluate(() => ({
|
||
scroll: document.documentElement.scrollWidth,
|
||
sichtbar: document.documentElement.clientWidth,
|
||
}));
|
||
if (masse.scroll > masse.sichtbar) {
|
||
throw new Error(`${seite.pfad} scrollt am Handy horizontal: ${masse.scroll} > ${masse.sichtbar}`);
|
||
}
|
||
}
|
||
|
||
console.log(` ${datei}`);
|
||
await kontext.close();
|
||
}
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
console.log("Screenshots erzeugt.");
|