Was macht UI-Demo-Video-Recorder?
Zeichne polierte Demo-Videos von Webanwendungen mit Playwrights Videoaufzeichnung auf, einschließlich einer eingebetteten Cursor-Überlagerung, natürlichem Tempo und einer erzählerischen Struktur.
Wann verwenden
- Der Benutzer fragt nach einem "Demo-Video", "Bildschirmaufzeichnung", "Walkthrough" oder "Tutorial"
- Der Benutzer möchte eine Funktion oder einen Workflow visuell präsentieren
- Der Benutzer benötigt ein Video für Dokumentation, Onboarding oder Präsentationen vor Stakeholdern
Dreiphasenprozess
Jede Demo durchläuft drei Phasen: Entdecken -> Proben -> Aufnehmen. Niemals direkt zur Aufnahme springen.
Phase 1: Entdecken
Bevor ein Skript geschrieben wird, erkunde die Zielseiten, um zu verstehen, was tatsächlich vorhanden ist.
Warum
Man kann nichts skripten, was man nicht gesehen hat. Felder können <input> und nicht <textarea> sein, Dropdowns können benutzerdefinierte Komponenten und nicht <select> sein, und Kommentarfelder können @Erwähnungen oder #Tags unterstützen. Annahmen brechen Aufnahmen stillschweigend.
Wie
Navigiere zu jeder Seite im Workflow und gib ihre interaktiven Elemente aus:
// Führe dies für jede Seite im Workflow AUS, bevor das Demo-Skript geschrieben wird
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: Gib Optionswerte UND Text aus. Platzhalter haben oft
value="0"odervalue="", was nicht leer aussieht. VerwendeArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Überspringe Optionen, deren Text "Auswählen" enthält oder deren Wert"0"ist. - Rich Text: Unterstützt das Kommentarfeld
@Erwähnungen,#Tags, Markdown oder Emojis? Überprüfe den Platzhaltertext. - Pflichtfelder: Welche Felder blockieren die Formularübermittlung? Überprüfe
required,*in Labels und versuche, das Formular leer abzusenden, um Validierungsfehler zu sehen. - Dynamischer Inhalt: Werden Felder erst sichtbar, nachdem andere Felder ausgefüllt wurden?
- Schaltflächenbeschriftungen: Exakter Text wie
"Absenden","Anfrage absenden"oder"Senden". - Tabellenkopfzeilen: Ordne bei tabellengesteuerten Modalen jedes
input[type="number"]seiner Kopfzeile zu, 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:
- Budget Code: <select> (erstes Select auf der Seite, 4 Optionen)
- Gewünschte Lieferung: <input type="date">
- Kontext: <textarea> (nicht input)
- BOM-Tabelle: inline-editable Zellen mit span.cursor-pointer -> input-Muster
- Absenden: <button> text="Absenden"
/purchase-requests/N (Detail):
- Kommentar: <input placeholder="Nachricht eingeben..."> unterstützt @user und #PR-Tags
- Senden: <button> text="Senden" (deaktiviert, bis das Eingabefeld Inhalt hat)
Phase 2: Proben
Führe alle Schritte ohne Aufzeichnung aus. Überprüfe, ob jeder Selektor aufgelöst werden kann.
Warum
Stille Selektorfehler sind der Hauptgrund, warum Demo-Aufnahmen fehlschlagen. Das Proben fängt sie ab, bevor eine Aufnahme verschwendet wird.
Wie
Verwende ensureVisible, 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;
}
Probenskriptstruktur
const steps = [
{ label: 'E-Mail-Feld anmelden', selector: '#email' },
{ label: 'Anmelden-Button', selector: 'button[type="submit"]' },
{ label: 'Neue Anfrage Schaltfläche', selector: 'button:has-text("Neue Anfrage")' },
{ label: 'Budget Code Select', selector: 'select' },
{ label: 'Lieferdatum', selector: 'input[type="date"]:visible' },
{ label: 'Beschreibungsfeld', selector: 'textarea:visible' },
{ label: 'Element hinzufügen Schaltfläche', 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 verifiziert');
Wenn das Proben fehlschlägt
- Lies die Ausgabe der sichtbaren Elemente.
- Finde den korrekten Selektor.
- Aktualisiere das Skript.
- Führe das Proben erneut aus.
- Fahre nur fort, wenn jeder Selektor bestanden wird.
Phase 3: Aufnehmen
Erst nach erfolgreichem Entdecken und Proben sollte die Aufnahme erstellt werden.
Aufnahmeprinzipien
1. Erzählerische Struktur
Plane das Video als Geschichte. Befolge die vom Benutzer angegebene Reihenfolge oder verwende diese Standardreihenfolge:
- Einstieg: Anmelden oder zum Startpunkt navigieren
- Kontext: Überfliege die Umgebung, damit sich die Zuschauer orientieren können
- Aktion: Führe die Hauptarbeitsschritte aus
- Variation: Zeige eine sekundäre Funktion wie Einstellungen, Design oder Lokalisierung
- Ergebnis: Zeige das Ergebnis, die Bestätigung oder den neuen Zustand
2. Tempo
- Nach der Anmeldung:
4s - Nach der Navigation:
3s - Nach dem Klicken einer Schaltfläche:
2s - Zwischen größeren Schritten:
1,5-2s - Nach der letzten Aktion:
3s - Eingabeverzögerung:
25-40mspro Zeichen
3. Cursor-Überlagerung
Füge einen SVG-Pfeil-Cursor ein, der den 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';
});
});
}
Rufe injectCursor(page) nach jeder Seitennavigation auf, da die Überlagerung bei Navigation zerstört wird.
4. Mausbewegung
Teleportiere den Cursor niemals. Bewege dich zum Ziel, bevor geklickt wird:
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. Eingabe
Gib sichtbar ein, nicht sofort fü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
Verwende sanftes Scrollen anstelle von Sprüngen:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Dashboard-Panning
Wenn ein Dashboard oder eine Übersichtsseite gezeigt wird, bewege den Cursor über die wichtigsten Elemente:
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
Füge eine Untertitelzeile am unteren Rand des Viewports ein:
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);
}
Rufe injectSubtitleBar(page) zusammen mit injectCursor(page) nach jeder Navigation auf.
Verwendungsmuster:
await showSubtitle(page, 'Schritt 1 - Anmelden');
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
await showSubtitle(page, '');
Richtlinien:
- Halte den Untertitel kurz, idealerweise unter 60 Zeichen.
- Verwende das Format
Schritt N - Aktionfür Konsistenz. - Lösche den Untertitel während längerer Pausen, in denen die UI für sich selbst sprechen kann.
Skriptvorlage
'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');
// Füge hier injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible und panElements ein.
(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();
// Navigiere durch den Workflow und führe ensureVisible für jeden Selektor aus.
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 - Anmelden');
// Anmeldeaktionen
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
// Dashboard-Panning
await showSubtitle(page, 'Schritt 3 - Hauptworkflow');
// 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
- Proben bestanden, alle Selektoren OK
- Headless-Modus aktiviert
- Auflösung auf
1280x720eingestellt - Cursor- und Untertitelüberlagerungen nach jeder Navigation neu eingefügt
-
showSubtitle(page, 'Schritt N - ...')an wichtigen Übergängen verwendet -
moveAndClickfür alle Klicks mit beschreibenden Labels verwendet -
typeSlowlyfür sichtbare Eingaben verwendet - Keine stillen Abfangmechanismen;
… [für Übersetzung abgeschnitten]