Was macht UI-Demo-Video-Recorder?
Erstellt polierte Demo-Videos von Webanwendungen mit Playwrights Videoaufzeichnung, einem eingeblendeten Mauszeiger-Overlay, natürlichem Tempo und erzählerischem Flow.
Wann verwenden
- Der Nutzer fragt nach einem "Demo-Video", "Bildschirmaufnahme", "Walkthrough" oder "Tutorial"
- Der Nutzer möchte eine Funktion oder einen Arbeitsablauf visuell präsentieren
- Der Nutzer benötigt ein Video für die Dokumentation, das Onboarding oder eine Präsentation für Stakeholder
Drei-Phasen-Prozess
Jedes Demo durchläuft drei Phasen: Entdecken -> Proben -> Aufnehmen. Nie direkt zur Aufnahme springen.
Phase 1: Entdecken
Bevor ein Skript geschrieben wird, die Zielseiten erkunden, um zu verstehen, was tatsächlich vorhanden ist.
Warum
Man kann kein Skript für etwas schreiben, das man nicht gesehen hat. Felder können <input> statt <textarea> sein, Dropdowns können benutzerdefinierte Komponenten statt <select> sein, und Kommentarfelder können @Erwähnungen oder #Tags unterstützen. Annahmen zerstören Aufnahmen stillschweigend.
Wie
Zu jeder Seite im Ablauf navigieren und deren interaktive Elemente ausgeben:
// Dies für jede Seite im Ablauf VOR dem Schreiben des Demo-Skripts ausführen
const fields = await page.evaluate(() => {
const els = [];
document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
if (el.offsetParent !== null) {
els.push({
tag: el.tagName,
type: el.type || '',
name: el.name || '',
placeholder: el.placeholder || '',
text: el.textContent?.trim().substring(0, 40) || '',
contentEditable: el.contentEditable === 'true',
role: el.getAttribute('role') || '',
});
}
});
return els;
});
console.log(JSON.stringify(fields, null, 2));
Worauf achten
- Formularfelder: Sind es
<select>,<input>, benutzerdefinierte Dropdowns oder Comboboxen? - Select-Optionen: Option-Werte UND -Text ausgeben. Platzhalter haben oft
value="0"odervalue="", was nicht leer aussieht.Array.from(el.options).map(o => ({ value: o.value, text: o.text }))verwenden. Optionen überspringen, bei deren Text "Auswählen" enthalten ist oder deren Wert"0"ist. - Rich-Text: Unterstützt das Kommentarfeld
@Erwähnungen,#Tags, Markdown oder Emojis? Platzhaltertext prüfen. - Pflichtfelder: Welche Felder blockieren das Absenden des Formulars?
required,*in Labels prüfen und versuchen, das Formular leer abzusenden, um Validierungsfehler zu sehen. - Dynamischer Inhalt: Erscheinen Felder erst, nachdem andere Felder ausgefüllt wurden?
- Button-Labels: Exakter Text wie
"Absenden","Antrag absenden"oder"Senden". - Tabellen-Überschriften: Bei tabellengesteuerten Modalen jedes
input[type="number"]seiner Spaltenüberschrift zuordnen, anstatt anzunehmen, dass alle numerischen Eingaben dasselbe bedeuten.
Ausgabe
Eine Feldkarte für jede Seite, die verwendet wird, um korrekte Selektoren im Skript zu schreiben. Beispiel:
/purchase-requests/new:
- Budgetcode: <select> (erstes Select auf der Seite, 4 Optionen)
- Gewünschte Lieferung: <input type="date">
- Kontext: <textarea> (nicht input)
- BOM-Tabelle: inline-editierbare Zellen mit span.cursor-pointer -> input-Muster
- Absenden: <button> text="Absenden"
/purchase-requests/N (Detail):
- Kommentar: <input placeholder="Nachricht eingeben..."> unterstützt @Benutzer und #PR-Tags
- Senden: <button> text="Senden" (deaktiviert, bis das Eingabefeld Inhalt hat)
Phase 2: Proben
Alle Schritte ohne Aufzeichnung durchlaufen. Überprüfen, ob sich jeder Selektor auflösen lässt.
Warum
Stille Selektorfehler sind der Hauptgrund, warum Demo-Aufnahmen fehlschlagen. Das Proben fängt sie ab, bevor eine Aufnahme verschwendet wird.
Wie
ensureVisible verwenden, einen Wrapper, der protokolliert und laut fehlschlägt:
async function ensureVisible(page, locator, label) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
const msg = `PROBEN FEHLGESCHLAGEN: "${label}" nicht gefunden - Selektor: ${typeof locator === 'string' ? locator : '(locator-Objekt)'}`;
console.error(msg);
const found = await page.evaluate(() => {
return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
.filter(el => el.offsetParent !== null)
.map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
.join('\n ');
});
console.error(' Sichtbare Elemente:\n ' + found);
return false;
}
console.log(`PROBEN OK: "${label}"`);
return true;
}
Struktur des Proben-Skripts
const steps = [
{ label: 'E-Mail-Feld Login', selector: '#email' },
{ label: 'Login absenden', selector: 'button[type="submit"]' },
{ label: 'Neuen Antrag-Button', selector: 'button:has-text("Neuer Antrag")' },
{ label: 'Budgetcode-Auswahl', selector: 'select' },
{ label: 'Lieferdatum', selector: 'input[type="date"]:visible' },
{ label: 'Beschreibungsfeld', selector: 'textarea:visible' },
{ label: 'Element hinzufügen-Button', selector: 'button:has-text("Element hinzufügen")' },
{ label: 'Absenden-Button', selector: 'button:has-text("Absenden")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('PROBEN FEHLGESCHLAGEN - Selektoren vor der Aufnahme korrigieren');
process.exit(1);
}
console.log('PROBEN BESTANDEN - alle Selektoren überprüft');
Wenn das Proben fehlschlägt
- Die Ausgabe der sichtbaren Elemente lesen.
- Den korrekten Selektor finden.
- Das Skript aktualisieren.
- Das Proben erneut ausführen.
- Nur fortfahren, wenn jeder Selektor bestanden ist.
Phase 3: Aufnehmen
Nur nachdem Entdecken und Proben bestanden sind, die Aufnahme erstellen.
Aufnahmeprinzipien
1. Erzählerischer Flow
Das Video als Geschichte planen. Vom Benutzer festgelegte Reihenfolge befolgen oder diese Standardreihenfolge verwenden:
- Einstieg: Einloggen oder zum Startpunkt navigieren
- Kontext: Die Umgebung schwenken, damit sich die Zuschauer orientieren können
- Aktion: Die Haupt-Workflow-Schritte ausführen
- Variation: Eine sekundäre Funktion wie Einstellungen, Design oder Lokalisierung zeigen
- Ergebnis: Das Ergebnis, die Bestätigung oder den neuen Zustand zeigen
2. Tempo
- Nach dem Login:
4s - Nach der Navigation:
3s - Nach einem Button-Klick:
2s - Zwischen großen Schritten:
1.5-2s - Nach der letzten Aktion:
3s - Tippverzögerung:
25-40mspro Zeichen
3. Mauszeiger-Overlay
Einen SVG-Pfeil-Mauszeiger einfügen, der Mausbewegungen folgt:
async function injectCursor(page) {
await page.evaluate(() => {
if (document.getElementById('demo-cursor')) return;
const cursor = document.createElement('div');
cursor.id = 'demo-cursor';
cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`;
cursor.style.cssText = `
position: fixed; z-index: 999999; pointer-events: none;
width: 24px; height: 24px;
transition: left 0.1s, top 0.1s;
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
`;
cursor.style.left = '0px';
cursor.style.top = '0px';
document.body.appendChild(cursor);
document.addEventListener('mousemove', (e) => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
});
});
}
injectCursor(page) nach jeder Seitennavigation aufrufen, da das Overlay bei Navigation zerstört wird.
4. Mausbewegung
Den Mauszeiger niemals teleportieren. Vor dem Klicken zum Ziel bewegen:
async function moveAndClick(page, locator, label, opts = {}) {
const { postClickDelay = 800, ...clickOpts } = opts;
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNUNG: moveAndClick übersprungen - "${label}" nicht sichtbar`);
return false;
}
try {
await el.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const box = await el.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
await page.waitForTimeout(400);
}
await el.click(clickOpts);
} catch (e) {
console.error(`WARNUNG: moveAndClick fehlgeschlagen bei "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Jeder Aufruf sollte ein beschreibendes label für die Fehlersuche enthalten.
5. Tippen
Sichtbar tippen, nicht sofort ausfüllen:
async function typeSlowly(page, locator, text, label, charDelay = 35) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNUNG: typeSlowly übersprungen - "${label}" nicht sichtbar`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Scrollen
Sanftes Scrollen anstelle von Sprüngen verwenden:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Dashboard-Schwenk
Beim Anzeigen eines Dashboards oder einer Übersichtsseite den Mauszeiger über die wichtigsten Elemente bewegen:
async function panElements(page, selector, maxCount = 6) {
const elements = await page.locator(selector).all();
for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
try {
const box = await elements[i].boundingBox();
if (box && box.y < 700) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
await page.waitForTimeout(600);
}
} catch (e) {
console.warn(`WARNUNG: panElements übersprungen Element ${i} (Selektor: "${selector}"): ${e.message}`);
}
}
}
8. Untertitel
Eine Untertitel-Leiste am unteren Rand des Viewports einfügen:
async function injectSubtitleBar(page) {
await page.evaluate(() => {
if (document.getElementById('demo-subtitle')) return;
const bar = document.createElement('div');
bar.id = 'demo-subtitle';
bar.style.cssText = `
position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
text-align: center; padding: 12px 24px;
background: rgba(0, 0, 0, 0.75);
color: white; font-family: -apple-system, "Segoe UI", sans-serif;
font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
transition: opacity 0.3s;
pointer-events: none;
`;
bar.textContent = '';
bar.style.opacity = '0';
document.body.appendChild(bar);
});
}
async function showSubtitle(page, text) {
await page.evaluate((t) => {
const bar = document.getElementById('demo-subtitle');
if (!bar) return;
if (t) {
bar.textContent = t;
bar.style.opacity = '1';
} else {
bar.style.opacity = '0';
}
}, text);
if (text) await page.waitForTimeout(800);
}
injectSubtitleBar(page) zusammen mit injectCursor(page) nach jeder Navigation aufrufen.
Verwendungsmuster:
await showSubtitle(page, 'Schritt 1 - Einloggen');
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
await showSubtitle(page, '');
Richtlinien:
- Untertiteltext kurz halten, idealerweise unter 60 Zeichen.
- Das Format
Schritt N - Aktionfür Konsistenz verwenden. - Den Untertitel bei längeren Pausen löschen, in denen die UI für sich selbst sprechen kann.
Skript-Vorlage
'use strict';
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
const VIDEO_DIR = path.join(__dirname, 'screenshots');
const OUTPUT_NAME = 'demo-FEATURE.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// Hier injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible und panElements einfügen.
(async () => {
const browser = await chromium.launch({ headless: true });
if (REHEARSAL) {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
// Durch den Ablauf navigieren und ensureVisible für jeden Selektor ausführen.
await browser.close();
return;
}
const context = await browser.newContext({
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
viewport: { width: 1280, height: 720 }
});
const page = await context.newPage();
try {
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Schritt 1 - Einloggen');
// Login-Aktionen
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
// Dashboard schwenken
await showSubtitle(page, 'Schritt 3 - Haupt-Workflow');
// Aktionssequenz
await showSubtitle(page, 'Schritt 4 - Ergebnis');
// Endgültige Enthüllung
await showSubtitle(page, '');
} catch (err) {
console.error('DEMO-FEHLER:', err.message);
} finally {
await context.close();
const video = page.video();
if (video) {
const src = await video.path();
const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
try {
fs.copyFileSync(src, dest);
console.log('Video gespeichert:', dest);
} catch (e) {
console.error('FEHLER: Video konnte nicht kopiert werden:', e.message);
console.error(' Quelle:', src);
console.error(' Ziel:', dest);
}
}
await browser.close();
}
})();
Verwendung:
# Phase 2: Proben
node demo-script.cjs --rehearse
# Phase 3: Aufnehmen
node demo-script.cjs
Checkliste vor der Aufnahme
- Entdeckungsphase abgeschlossen
- Probe bestanden mit allen OK-Selektoren
- Headless-Modus aktiviert
- Auflösung auf
1280x720eingestellt - Mauszeiger- und Untertitel-Overlays nach jeder Navigation neu eingefügt
-
showSubtitle(page, 'Schritt N - ...')bei wichtigen Übergängen verwendet -
moveAndClickfür alle Klicks mit beschreibenden Labels verwendet -
typeSlowlyfür sichtbare Eingaben verwendet - Keine stillen Catches; ...
[zur Übersetzung gekürzt]
Hinweis: Der ursprüngliche englische Text wurde aus technischen Gründen gekürzt. Bitte die vollständigen Funktionsimplementierungen aus dem Original übernehmen.