Grabador de Videos Demostrativos de UI
Graba videos de demostración pulidos de aplicaciones web usando la grabación de video de Playwright con una superposición de cursor inyectada, ritmo natural y flujo narrativo.
Cuándo Usarlo
- El usuario pide un "video demo", "grabación de pantalla", "tutorial" o "walkthrough"
- El usuario quiere mostrar visualmente una funcionalidad o flujo de trabajo
- El usuario necesita un video para documentación, onboarding o presentación a los interesados
Proceso de Tres Fases
Cada demo pasa por tres fases: Descubrir -> Ensayar -> Grabar. Nunca vayas directamente a grabar.
Fase 1: Descubrir
Antes de escribir cualquier script, explora las páginas de destino para entender lo que realmente hay.
Por qué
No puedes escribir un script para lo que no has visto. Los campos pueden ser <input> y no <textarea>, los menús desplegables pueden ser componentes personalizados y no <select>, y los cuadros de comentarios pueden soportar @menciones o #etiquetas. Las suposiciones rompen las grabaciones en silencio.
Cómo
Navega a cada página en el flujo y vuelca sus elementos interactivos:
// Ejecuta esto para cada página en el flujo ANTES de escribir el script del demo
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));
Qué buscar
- Campos de formulario: ¿Son
<select>,<input>, menús desplegables personalizados o comboboxes? - Opciones de selección: Vuelca los valores y el texto de las opciones. Los placeholders a menudo tienen
value="0"ovalue=""que parece no vacío. UsaArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Omite opciones donde el texto incluya "Seleccionar" o el valor sea"0". - Texto enriquecido: ¿El cuadro de comentarios soporta
@menciones,#etiquetas, markdown o emojis? Verifica el texto del placeholder. - Campos obligatorios: ¿Qué campos bloquean el envío del formulario? Verifica
required,*en las etiquetas e intenta enviar vacío para ver errores de validación. - Contenido dinámico: ¿Aparecen campos después de llenar otros?
- Etiquetas de botones: Texto exacto como
"Enviar","Enviar Solicitud"o"Enviar". - Encabezados de columnas de tabla: Para modales basados en tablas, mapea cada
input[type="number"]a su encabezado de columna en lugar de asumir que todas las entradas numéricas significan lo mismo.
Salida
Un mapa de campos para cada página, usado para escribir selectores correctos en el script. Ejemplo:
/purchase-requests/new:
- Código Presupuestario: <select> (primer select en la página, 4 opciones)
- Fecha de Entrega Deseada: <input type="date">
- Contexto: <textarea> (no input)
- Tabla BOM: celdas editables en línea con span.cursor-pointer -> patrón input
- Enviar: <button> text="Enviar"
/purchase-requests/N (detalle):
- Comentario: <input placeholder="Escribe un mensaje..."> soporta @usuario y #PR
- Enviar: <button> text="Enviar" (deshabilitado hasta que la entrada tenga contenido)
Fase 2: Ensayar
Ejecuta todos los pasos sin grabar. Verifica que cada selector se resuelva.
Por qué
Los fallos silenciosos de selectores son la razón principal por la que las grabaciones de demo se rompen. El ensayo los detecta antes de desperdiciar una grabación.
Cómo
Usa ensureVisible, un envoltorio que registra y falla ruidosamente:
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 = `ENSAYO FALLÓ: "${label}" no encontrado - selector: ${typeof locator === 'string' ? locator : '(objeto localizador)'}`;
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(' Elementos visibles:\n ' + found);
return false;
}
console.log(`ENSAYO OK: "${label}"`);
return true;
}
Estructura del script de ensayo
const steps = [
{ label: 'Campo de email de inicio de sesión', selector: '#email' },
{ label: 'Botón de inicio de sesión', selector: 'button[type="submit"]' },
{ label: 'Botón Nueva Solicitud', selector: 'button:has-text("Nueva Solicitud")' },
{ label: 'Selector de Código Presupuestario', selector: 'select' },
{ label: 'Fecha de entrega', selector: 'input[type="date"]:visible' },
{ label: 'Campo de descripción', selector: 'textarea:visible' },
{ label: 'Botón Agregar Artículo', selector: 'button:has-text("Agregar Artículo")' },
{ label: 'Botón Enviar', selector: 'button:has-text("Enviar")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('ENSAYO FALLÓ - corrige los selectores antes de grabar');
process.exit(1);
}
console.log('ENSAYO PASÓ - todos los selectores verificados');
Cuando el ensayo falla
- Lee el volcado de elementos visibles.
- Encuentra el selector correcto.
- Actualiza el script.
- Vuelve a ejecutar el ensayo.
- Solo procede cuando todos los selectores pasen.
Fase 3: Grabar
Solo después de que la fase de descubrimiento y el ensayo pasen, debes crear la grabación.
Principios de Grabación
1. Flujo Narrativo
Planifica el video como una historia. Sigue el orden especificado por el usuario, o usa este orden por defecto:
- Entrada: Inicio de sesión o navegación al punto de inicio
- Contexto: Muestra el entorno para que los espectadores se orienten
- Acción: Realiza los pasos principales del flujo de trabajo
- Variación: Muestra una funcionalidad secundaria como configuración, tema o localización
- Resultado: Muestra el resultado, confirmación o nuevo estado
2. Ritmo
- Después del inicio de sesión:
4s - Después de la navegación:
3s - Después de hacer clic en un botón:
2s - Entre pasos principales:
1.5-2s - Después de la acción final:
3s - Retraso de escritura:
25-40mspor carácter
3. Superposición de Cursor
Inyecta un cursor de flecha SVG que sigue los movimientos del mouse:
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';
});
});
}
Llama a injectCursor(page) después de cada navegación a una página porque la superposición se destruye al navegar.
4. Movimiento del Mouse
Nunca teletransportes el cursor. Muévelo al objetivo antes de hacer clic:
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(`ADVERTENCIA: moveAndClick omitido - "${label}" no 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(`ADVERTENCIA: moveAndClick falló en "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Cada llamada debe incluir una label descriptiva para depuración.
5. Escritura
Escribe visiblemente, no con llenado instantáneo:
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(`ADVERTENCIA: typeSlowly omitido - "${label}" no visible`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Desplazamiento
Usa desplazamiento suave en lugar de saltos:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Recorrido de Paneles
Al mostrar un panel o página de resumen, mueve el cursor sobre los elementos clave:
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(`ADVERTENCIA: panElements omitió el elemento ${i} (selector: "${selector}"): ${e.message}`);
}
}
}
8. Subtítulos
Inyecta una barra de subtítulos en la parte inferior de la ventana:
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);
}
Llama a injectSubtitleBar(page) junto con injectCursor(page) después de cada navegación.
Patrón de uso:
await showSubtitle(page, 'Paso 1 - Iniciando sesión');
await showSubtitle(page, 'Paso 2 - Resumen del panel');
await showSubtitle(page, '');
Directrices:
- Mantén el texto del subtítulo corto, idealmente menos de 60 caracteres.
- Usa el formato
Paso N - Acciónpara consistencia. - Limpia el subtítulo durante pausas largas donde la UI puede hablar por sí misma.
Plantilla 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');
// Pega aquí injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible y panElements.
(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();
// Navega por el flujo y ejecuta ensureVisible para cada selector.
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, 'Paso 1 - Iniciando sesión');
// acciones de inicio de sesión
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Paso 2 - Resumen del panel');
// recorre el panel
await showSubtitle(page, 'Paso 3 - Flujo de trabajo principal');
// secuencia de acciones
await showSubtitle(page, 'Paso 4 - Resultado');
// revelación final
await showSubtitle(page, '');
} catch (err) {
console.error('ERROR DEL DEMO:', 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 guardado:', dest);
} catch (e) {
console.error('ERROR: No se pudo copiar el video:', e.message);
console.error(' Origen:', src);
console.error(' Destino:', dest);
}
}
await browser.close();
}
})();
Uso:
# Fase 2: Ensayar
node demo-script.cjs --rehearse
# Fase 3: Grabar
node demo-script.cjs
Lista de Verificación Antes de Grabar
- Fase de descubrimiento completada
- Ensayo pasa con todos los selectores OK
- Modo headless habilitado
- Resolución configurada a
1280x720 - Superposiciones de cursor y subtítulos reinyectadas después de cada navegación
-
showSubtitle(page, 'Paso N - ...')usado en transiciones principales -
moveAndClickusado para todos los clics con etiquetas descriptivas -
typeSlowlyusado para entrada visible - Sin capturas silenciosas; usa
try/catchcon registro de advertencia -
panElementsusado para páginas de panel/resumen - Tiempos de espera post-acción aplicados (2-3s para pasos principales, 1-2s para clics, 4s después de navegación/inicio de sesión)