Enregistreur de vidéos de démonstration UI
Enregistrez des vidéos de démonstration soignées d'applications web en utilisant l'enregistrement vidéo de Playwright avec un curseur superposé, un rythme naturel et un flux narratif.
Quand utiliser
- L'utilisateur demande une "vidéo de démonstration", un "enregistrement d'écran", une "procédure pas à pas" ou un "tutoriel"
- L'utilisateur souhaite présenter une fonctionnalité ou un workflow visuellement
- L'utilisateur a besoin d'une vidéo pour la documentation, l'intégration ou une présentation aux parties prenantes
Processus en trois phases
Chaque démonstration passe par trois phases : Découvrir -> Répéter -> Enregistrer. Ne passez jamais directement à l'enregistrement.
Phase 1 : Découvrir
Avant d'écrire un script, explorez les pages cibles pour comprendre ce qui s'y trouve réellement.
Pourquoi
Vous ne pouvez pas scripter ce que vous n'avez pas vu. Les champs peuvent être <input> et non <textarea>, les menus déroulants peuvent être des composants personnalisés et non <select>, et les zones de commentaires peuvent prendre en charge les @mentions ou les #tags. Les hypothèses rompent les enregistrements en silence.
Comment
Naviguez vers chaque page du flux et extrayez ses éléments interactifs :
// Exécutez ceci pour chaque page du flux AVANT d'écrire le script de démonstration
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));
Ce qu'il faut rechercher
- Champs de formulaire : Sont-ils des
<select>,<input>, des menus déroulants personnalisés ou des boîtes combinées ? - Options de sélection : Extrayez les valeurs ET le texte des options. Les espaces réservés ont souvent
value="0"ouvalue=""qui semble non vide. UtilisezArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Ignorez les options où le texte inclut "Sélectionner" ou la valeur est"0". - Texte enrichi : La zone de commentaire prend-elle en charge les
@mentions, les#tags, le markdown ou les emoji ? Vérifiez le texte de l'espace réservé. - Champs obligatoires : Quels champs bloquent la soumission du formulaire ? Vérifiez
required,*dans les libellés, et essayez de soumettre vide pour voir les erreurs de validation. - Contenu dynamique : Les champs apparaissent-ils après que d'autres champs sont remplis ?
- Libellés des boutons : Texte exact comme
"Soumettre","Soumettre la demande"ou"Envoyer". - En-têtes de colonnes de tableau : Pour les modaux basés sur des tableaux, mappez chaque
input[type="number"]à son en-tête de colonne au lieu de supposer que toutes les entrées numériques signifient la même chose.
Résultat
Une carte des champs pour chaque page, utilisée pour écrire des sélecteurs corrects dans le script. Exemple :
/purchase-requests/new:
- Budget Code: <select> (premier select de la page, 4 options)
- Livraison souhaitée: <input type="date">
- Contexte: <textarea> (pas input)
- Tableau BOM: cellules éditées en ligne avec span.cursor-pointer -> modèle input
- Soumettre: <button> text="Soumettre"
/purchase-requests/N (détail):
- Commentaire: <input placeholder="Tapez un message..."> prend en charge @utilisateur et #PR tags
- Envoyer: <button> text="Envoyer" (désactivé tant que l'entrée n'a pas de contenu)
Phase 2 : Répéter
Exécutez toutes les étapes sans enregistrer. Vérifiez que chaque sélecteur se résout.
Pourquoi
Les échecs silencieux de sélecteurs sont la principale raison pour laquelle les enregistrements de démonstration échouent. La répétition les attrape avant de gaspiller un enregistrement.
Comment
Utilisez ensureVisible, un wrapper qui enregistre et échoue bruyamment :
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 = `ÉCHEC DE RÉPÉTITION : "${label}" introuvable - sélecteur : ${typeof locator === 'string' ? locator : '(objet locator)'}`;
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(' Éléments visibles :\n ' + found);
return false;
}
console.log(`RÉPÉTITION OK : "${label}"`);
return true;
}
Structure du script de répétition
const steps = [
{ label: 'Champ email de connexion', selector: '#email' },
{ label: 'Bouton de soumission de connexion', selector: 'button[type="submit"]' },
{ label: 'Bouton Nouvelle demande', selector: 'button:has-text("Nouvelle demande")' },
{ label: 'Sélection du budget', selector: 'select' },
{ label: 'Date de livraison', selector: 'input[type="date"]:visible' },
{ label: 'Champ de description', selector: 'textarea:visible' },
{ label: 'Bouton Ajouter un élément', selector: 'button:has-text("Ajouter un élément")' },
{ label: 'Bouton Soumettre', selector: 'button:has-text("Soumettre")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('RÉPÉTITION ÉCHOUÉE - corrigez les sélecteurs avant d\'enregistrer');
process.exit(1);
}
console.log('RÉPÉTITION RÉUSSIE - tous les sélecteurs vérifiés');
En cas d'échec de la répétition
- Lisez le vidage des éléments visibles.
- Trouvez le bon sélecteur.
- Mettez à jour le script.
- Ré-exécutez la répétition.
- Ne continuez que lorsque chaque sélecteur réussit.
Phase 3 : Enregistrer
Créez l'enregistrement uniquement après la découverte et la répétition réussies.
Principes d'enregistrement
1. Flux narratif
Planifiez la vidéo comme une histoire. Suivez l'ordre spécifié par l'utilisateur, ou utilisez cet ordre par défaut :
- Entrée : Connectez-vous ou naviguez jusqu'au point de départ
- Contexte : Parcourez les environs pour que les spectateurs s'orientent
- Action : Effectuez les étapes principales du workflow
- Variation : Montrez une fonctionnalité secondaire comme les paramètres, le thème ou la localisation
- Résultat : Montrez le résultat, la confirmation ou le nouvel état
2. Rythme
- Après la connexion :
4s - Après la navigation :
3s - Après un clic sur un bouton :
2s - Entre les étapes principales :
1.5-2s - Après l'action finale :
3s - Délai de frappe :
25-40mspar caractère
3. Superposition du curseur
Injectez un curseur fléché SVG qui suit les mouvements de la souris :
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';
});
});
}
Appelez injectCursor(page) après chaque navigation, car la superposition est détruite lors de la navigation.
4. Mouvement de la souris
Ne téléportez jamais le curseur. Déplacez-vous vers la cible avant de cliquer :
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(`ATTENTION : moveAndClick ignoré - "${label}" non visible`);
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(`ATTENTION : moveAndClick a échoué sur "${label}" : ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Chaque appel doit inclure un label descriptif pour le débogage.
5. Saisie
Tapez visiblement, pas en remplissage instantané :
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(`ATTENTION : typeSlowly ignoré - "${label}" non visible`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Défilement
Utilisez un défilement fluide plutôt que des sauts :
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Parcours du tableau de bord
Lorsque vous montrez un tableau de bord ou une page de vue d'ensemble, déplacez le curseur sur les éléments clés :
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(`ATTENTION : panElements a ignoré l'élément ${i} (sélecteur : "${selector}") : ${e.message}`);
}
}
}
8. Sous-titres
Injectez une barre de sous-titres en bas de la fenêtre :
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);
}
Appelez injectSubtitleBar(page) en même temps que injectCursor(page) après chaque navigation.
Modèle d'utilisation :
await showSubtitle(page, 'Étape 1 - Connexion');
await showSubtitle(page, 'Étape 2 - Aperçu du tableau de bord');
await showSubtitle(page, '');
Conseils :
- Gardez le texte du sous-titre court, idéalement moins de 60 caractères.
- Utilisez le format
Étape N - Actionpour la cohérence. - Effacez le sous-titre pendant les longues pauses où l'interface utilisateur peut parler d'elle-même.
Modèle de script
'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');
// Collez injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible, et panElements ici.
(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();
// Naviguez à travers le flux et exécutez ensureVisible pour chaque sélecteur.
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, 'Étape 1 - Connexion');
// actions de connexion
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Étape 2 - Aperçu du tableau de bord');
// parcourir le tableau de bord
await showSubtitle(page, 'Étape 3 - Workflow principal');
// séquence d'actions
await showSubtitle(page, 'Étape 4 - Résultat');
// révélation finale
await showSubtitle(page, '');
} catch (err) {
console.error('ERREUR DE DÉMO :', 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('Vidéo enregistrée :', dest);
} catch (e) {
console.error('ERREUR : Échec de la copie de la vidéo :', e.message);
console.error(' Source :', src);
console.error(' Destination :', dest);
}
}
await browser.close();
}
})();
Utilisation :
# Phase 2 : Répéter
node demo-script.cjs --rehearse
# Phase 3 : Enregistrer
node demo-script.cjs
Liste de contrôle avant l'enregistrement
- Phase de découverte terminée
- La répétition réussit avec tous les sélecteurs OK
- Mode headless activé
- Résolution définie sur
1280x720 - Superpositions du curseur et des sous-titres réinjectées après chaque navigation
-
showSubtitle(page, 'Étape N - ...')utilisé aux transitions principales -
moveAndClickutilisé pour tous les clics avec des étiquettes descriptives -
typeSlowlyutilisé pour les saisies visibles - Pas de captures silencieuses ;