Wir entwickeln bei mark veys unter anderem Patienten-Apps aus dem Pharma-Umfeld. Dort gehört zu jedem Release ein PDF, das jeden Screen der App zeigt: jeden Schritt eines Ablaufs, jeden Dialog, jeden Leer- und Fehlerzustand, für iOS und Android getrennt. Die Fachabteilung des Kunden prüft und gibt damit frei, was ausgeliefert wird. Bei einer der Apps sind das über 250 Screens auf gut 120 Seiten.
Von Hand lässt sich so ein Dokument einmal erstellen, aber nicht mit jeder Version: Eine neue Markenfarbe, und jedes Bild ist veraltet, auf beiden Plattformen. Die UI-Tests laufen ohnehin durch jeden dieser Zustände. Deshalb erzeugen wir das Dokument direkt aus ihnen.
Dazu gehören auch die Details, an denen solche Pipelines in der Praxis scheitern: Face ID im Simulator, native Slider, die Swipes ignorieren, und Flows, die nicht bei jedem Lauf gleich durchlaufen. In diesem Beitrag bauen wir die Tool-Chain nach, vom ersten Flow bis zum fertigen PDF, an einer kleinen Beispiel-App für Werkstatttermine.

Ändern Sie die Markenfarbe der Beispiel-App und erzeugen Sie die veralteten Screenshots durch einen Pipeline-Lauf neu.
Screenshots mit Maestro
Für die Screenshots verwenden wir Maestro, ein End-to-End-Testframework für mobile Apps. Es testet von außen: Maestro installiert eine Treiber-App auf dem Simulator, liest darüber den Accessibility-Baum der App und löst Eingaben aus, ohne den App-Code zu kennen. Auf iOS läuft das über Apples Entwicklerwerkzeuge, auf Android über ADB.
Die App muss dafür nur stabile Selektoren liefern. In React Native ist das die testID, die Maestro als id anspricht. Wo sich keine vergeben lässt, etwa in nativen Tab-Bars, wählt der Flow Elemente über ihren sichtbaren Text aus. Deshalb laufen unsere Flows in einer festen Sprache.
Ein Flow ist eine YAML-Datei mit einer Folge von Befehlen. Der für die Buchung sieht, auf die ersten beiden Screens gekürzt, so aus:
.maestro/flows/booking-screenshots.yaml
appId: ${APP_ID}
---
- launchApp
- runFlow: ../subflows/seed-test-data.yaml
- tapOn:
id: "home.new-booking"
- assertVisible:
id: "booking.service-list"
- waitForAnimationToEnd
- takeScreenshot: 01-service-01-list
- tapOn:
id: "booking.service-inspection"
- assertVisible:
id: "booking.slot-picker"
- waitForAnimationToEnd
- takeScreenshot: 02-slot-01-pickerJeder takeScreenshot schreibt eine PNG-Datei mit dem angegebenen Namen. Der Name ist zugleich der Schlüssel, über den der Generator später Bild und Beschreibung zusammenführt.
Wichtiger als die Befehle selbst ist, wann der Screenshot ausgelöst wird. assertVisible pollt, bis das Element erscheint oder der Timeout greift. waitForAnimationToEnd wartet, bis sich auf dem Screen nichts mehr bewegt. Erst dann ist der Screen fertig.

Spielen Sie einen Maestro-Flow ab, pausieren Sie ihn oder führen Sie einzelne Befehle aus. Simulator und Screenshot-Ordner zeigen das Ergebnis.
Hinweis
Auf iOS reagiert der nativeUISlidernicht zuverlässig auf einen Element-Swipe, der Wert bleibt auf 0. Wir ziehen dort langsam über Bildschirmkoordinaten. Android behält den Element-Swipe.
Reproduzierbare Testdaten
Gleiche Befehle ergeben noch keine gleichen Bilder. Steht beim einen Lauf ein Termin auf dem Startscreen und beim nächsten keiner, unterscheidet sich die Dokumentation, obwohl sich die App nicht geändert hat. Die Dev-Builds unserer Apps haben deshalb einen Deep Link, der den lokalen Speicher leert und einen festen Datensatz schreibt. Im Release-Build existiert er nicht. Jeder Flow ruft ihn zu Beginn als Subflow auf:
.maestro/subflows/seed-test-data.yaml
- openLink: "speiche:///debug/seed-test-data"
- extendedWaitUntil:
visible:
id: "home.screen"
timeout: 15000Ähnlich ist es mit Face ID. Unsere Apps fragen beim Start danach, und der iOS-Simulator wartet dann auf ein Match-Event. Das liefert eine Schleife, die neben dem Lauf alle zwei Sekunden eines sendet. Ist gerade kein Prompt offen, passiert nichts. Das betrifft nur den Simulator, die App selbst bleibt unverändert.
while true; do
xcrun simctl spawn booted notifyutil -p com.apple.BiometricKit_Sim.pearl.match
sleep 2
done &Alle Flows ausführen
Für den kompletten Lauf gibt es ein Shell-Skript. Es führt die Flows in der Reihenfolge aus, die ihre Manifeste vorgeben, und legt die Bilder nach Datum, Plattform und Ablauf ab. Weil Simulatoren nicht vollständig deterministisch sind, bekommt jeder Flow einen zweiten Versuch. Übernommen werden die Bilder nur aus einem vollständig erfolgreichen Durchlauf, ein abgebrochener Ablauf hinterlässt also kein halbes Kapitel.
.maestro/
├── flows/
│ ├── booking-screenshots.yaml
│ ├── booking-screenshots.manifest.yaml
│ └── …
├── subflows/
│ └── seed-test-data.yaml
├── screenshots/
│ └── 2026-09-28/
│ ├── ios/booking/01-service-01-list.png …
│ └── android/booking/…
└── screenshots.shDas Manifest
Die Screenshots sagen noch nichts darüber, was auf ihnen zu sehen ist oder wo sie im Dokument stehen. Das regelt ein Manifest, das neben jedem Flow liegt. Es enthält den Titel des Kapitels, seine Position im Dokument und zu jeder Screenshot-ID einen Satz:
.maestro/flows/booking-screenshots.manifest.yaml
flow: Termin buchen
order: 20
steps:
- id: 01-service-01-list
annotation: Leistung wählen
- id: 02-slot-01-picker
annotation: Freie Termine
- id: 03-confirm-01-choice
annotation: Zahlungsart wählen
branch:
paths:
Vor Ort:
- id: 04-onsite-01-summary
annotation: Zahlung vor Ort
Online:
- id: 05-online-01-card
annotation: Kartendaten eingebenDamit sind Ablauf und Inhalt getrennt. Der Flow beschreibt, wie man zu einem Screen kommt, das Manifest, was er zeigt. Ändert sich das Design, läuft nur der Flow neu. Ändert sich ein Text, bleibt der Test unangetastet, und die Beschreibungen kann auch pflegen, wer nie einen Flow geschrieben hat.

Das Diagramm zeigt, wie Maestro-Screenshots und Manifest-Texte über ihre IDs zusammenfinden und mit Playwright und pdf-lib zum PDF werden.
Den Rest leitet der Generator aus den IDs ab. Nach dem Schema NN-abschnitt-NN-zustand gruppiert er die Screens eines Kapitels in Abschnitte, 01-service-01-list landet im Abschnitt „service“. Verzweigungen unter branch.paths setzt er als parallele Spalten. Findet er zu einer ID kein Bild, bricht er nicht ab, sondern setzt einen sichtbaren Platzhalter und meldet die Lücke.

Bearbeiten Sie die Beschreibungen im Manifest und simulieren Sie einen fehlenden Screenshot. Die Dokumentationsseite aktualisiert sich direkt.
Das PDF mit Playwright
Aus Manifesten und Bildern baut der Generator zunächst eine HTML-Seite: ein Deckblatt mit App-Version, Plattform und Datum, ein Inhaltsverzeichnis und ein Kapitel pro Flow. Die Screenshots sind als Data-URIs eingebettet, die Seite hängt also an keinen weiteren Dateien.
Gedruckt wird sie mit Playwright. Aus der Library für Browser-Automatisierung und E2E-Tests nutzen wir hier nur page.pdf(), also die Print-Pipeline von Chromium, headless aus einem Node-Skript. Genau das war für uns der Grund für Playwright: Das Layout ist gewöhnliches HTML und CSS. Das Querformat steht per @page im CSS, Ränder und Papierformat in den Optionen von page.pdf(), und break-inside: avoid verhindert, dass ein Screenshot über einen Seitenumbruch läuft, und die Vorlage liegt versioniert neben den Flows.
Eine Einschränkung betrifft den Footer. Kopf- und Fußzeile übergibt man page.pdf() als eigene HTML-Fragmente. Chromium setzt darin Werte wie pageNumber und totalPages ein, erlaubt aber nur ein Template pro Aufruf. Um in jedem Kapitel dessen Namen im Footer zu zeigen, drucken wir die Kapitel einzeln und fügen sie anschließend mit pdf-lib zusammen:
generate-pdf/pdf-renderer.ts (vereinfacht)
const merged = await PDFDocument.create();
for (const flow of flows) {
// nur dieses Kapitel sichtbar machen
await page.evaluate((id) => {
for (const el of document.querySelectorAll(".flow"))
(el as HTMLElement).style.display = el.id === id ? "" : "none";
}, flow.id);
const part = await page.pdf({
format: "A4",
landscape: true,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: `<div style="font-size:8px">${flow.name}</div>`,
});
const doc = await PDFDocument.load(part);
const pages = await merged.copyPages(doc, doc.getPageIndices());
pages.forEach((p) => merged.addPage(p));
}Weil jedes Kapitel ein eigener Druckjob ist, würde Chromiums pageNumber in jedem Kapitel wieder bei 1 beginnen. Die Seitenzahlen setzt deshalb erst pdf-lib, nach dem Zusammenfügen: Es schreibt auf jede Seite „12 / 121“ per drawText.
Aus fertigen Screenshots entstehen so gut 120 Seiten in rund 15 Sekunden. Die Dauer des Gesamtlaufs bestimmt Maestro, weil jeder Flow in Echtzeit im Simulator läuft. Im Alltag sind es zwei Befehle:
pnpm screenshots:ios # alle Flows im Simulator
pnpm screenshots:pdf:ios # Manifeste + Screenshots → PDFFlows als Feedback für Coding-Agents
Die Suite ist Teil unseres Entwicklungsablaufs mit Coding-Agents. Ein Agent setzt ein Feature um, legt den passenden Flow an oder passt ihn an und führt gezielt diesen einen Ablauf aus:
pnpm screenshots:ios booking # nur booking-screenshots.yamlDie Screenshots sind dabei mehr als ein Nachweis. Der Agent sieht sich die Screens an und erkennt, wenn etwas kaputt ist, abgeschnitten wirkt oder vom Branding abweicht, und bessert nach, bevor ein Mensch draufschaut. Läuft alles, gehen die Screenshots ins Ticket. Braucht eine Abnahme mehr als Standbilder, schneidet der Agent aus den Flows auch ein Video zusammen.
Was der Agent über die Flows wissen muss, also Konventionen für testIDs, Namen der Screenshots und bekannte Workarounds, liegt als Skill im Repository und wird geladen, sobald eine Änderung .maestro/ betrifft.
Fazit
Angefangen hat das mit einem takeScreenshot pro Screen. Daraus ist eine End-to-End-Suite geworden, die die dokumentierten Abläufe auf beiden Plattformen durchspielt: für das PDF zu jedem Release und für jede Änderung, die ein Agent an der App macht.
Quellen: Maestro: How Maestro works · Maestro: Wait commands · Playwright: page.pdf() · pdf-lib
