UI 데모 비디오 레코더
Playwright의 비디오 녹화 기능과 주입된 커서 오버레이, 자연스러운 페이싱 및 스토리텔링 구성을 사용하여 웹 애플리케이션의 세련된 데모 비디오를 녹화합니다.
사용 시기
- 사용자가 "데모 비디오", "화면 녹화", "둘러보기", 또는 "튜토리얼"을 요청할 때
- 사용자가 기능이나 워크플로우를 시각적으로 선보이고 싶을 때
- 사용자가 문서화, 온보딩 또는 이해관계자 프레젠테이션을 위한 비디오가 필요할 때
3단계 프로세스
모든 데모는 발견(Discover) -> 리허설(Rehearse) -> 녹화(Record) 세 단계를 거칩니다. 절대 바로 녹화 단계로 건너뛰지 마세요.
1단계: 발견
스크립트를 작성하기 전에 먼저 대상 페이지를 탐색하여 실제로 무엇이 있는지 이해합니다.
이유
직접 보지 않은 것에 대해 스크립트를 작성할 수 없습니다. 필드는 <textarea>가 아닌 <input>일 수 있고, 드롭다운은 <select>가 아닌 커스텀 컴포넌트일 수 있으며, 댓글 상자는 @멘션이나 #태그를 지원할 수 있습니다. 가정은 조용히 녹화를 망칩니다.
방법
흐름에 있는 각 페이지로 이동하여 대화형 요소를 덤프합니다:
// 데모 스크립트를 작성하기 전에 흐름의 각 페이지에 대해 실행
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));
확인할 사항
- 양식 필드:
<select>,<input>, 커스텀 드롭다운 또는 콤보박스인가요? - 선택 옵션: 옵션 값과 텍스트를 모두 덤프합니다. 플레이스홀더는 종종
value="0"또는value=""값을 가지며 비어 있지 않아 보입니다.Array.from(el.options).map(o => ({ value: o.value, text: o.text }))를 사용하세요. 텍스트에 "선택"이 포함되거나 값이"0"인 옵션은 건너뜁니다. - 리치 텍스트: 댓글 상자가
@멘션,#태그, 마크다운 또는 이모지를 지원하나요? 플레이스홀더 텍스트를 확인하세요. - 필수 필드: 어떤 필드가 양식 제출을 차단하나요?
required, 레이블의*를 확인하고 빈 상태로 제출을 시도하여 유효성 검사 오류를 확인하세요. - 동적 콘텐츠: 다른 필드를 채운 후에 필드가 나타나나요?
- 버튼 레이블: "제출", "요청 제출" 또는 "보내기"와 같은 정확한 텍스트.
- 테이블 열 헤더: 테이블 기반 모달의 경우, 각
input[type="number"]를 해당 열 헤더에 매핑하고 모든 숫자 입력이 동일한 것을 의미한다고 가정하지 마세요.
결과물
각 페이지에 대한 필드 맵으로, 스크립트에 올바른 셀렉터를 작성하는 데 사용됩니다. 예시:
/purchase-requests/new:
- 예산 코드: <select> (페이지의 첫 번째 select, 4개 옵션)
- 희망 배송일: <input type="date">
- 컨텍스트: <textarea> (input 아님)
- BOM 테이블: 인라인 편집 가능한 셀, span.cursor-pointer -> input 패턴
- 제출: <button> text="제출"
/purchase-requests/N (세부 정보):
- 댓글: <input placeholder="메시지를 입력하세요..."> @user 및 #PR 태그 지원
- 보내기: <button> text="보내기" (입력 내용이 있을 때까지 비활성화)
2단계: 리허설
녹화 없이 모든 단계를 실행합니다. 모든 셀렉터가 확인되는지 검증합니다.
이유
조용한 셀렉터 실패는 데모 녹화가 깨지는 주된 이유입니다. 리허설은 녹화 시간을 낭비하기 전에 이를 포착합니다.
방법
ensureVisible을 사용하세요. 로그를 기록하고 실패 시 명확히 알리는 래퍼입니다:
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 = `리허설 실패: "${label}"을(를) 찾을 수 없음 - 셀렉터: ${typeof locator === 'string' ? locator : '(locator object)'}`;
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(' 보이는 요소들:\n ' + found);
return false;
}
console.log(`리허설 성공: "${label}"`);
return true;
}
리허설 스크립트 구조
const steps = [
{ label: '로그인 이메일 필드', selector: '#email' },
{ label: '로그인 제출', selector: 'button[type="submit"]' },
{ label: '새 요청 버튼', selector: 'button:has-text("새 요청")' },
{ label: '예산 코드 선택', selector: 'select' },
{ label: '배송일', selector: 'input[type="date"]:visible' },
{ label: '설명 필드', selector: 'textarea:visible' },
{ label: '항목 추가 버튼', selector: 'button:has-text("항목 추가")' },
{ label: '제출 버튼', selector: 'button:has-text("제출")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('리허설 실패 - 녹화 전에 셀렉터 수정');
process.exit(1);
}
console.log('리허설 통과 - 모든 셀렉터 확인됨');
리허설 실패 시
- 보이는 요소 덤프를 읽습니다.
- 올바른 셀렉터를 찾습니다.
- 스크립트를 업데이트합니다.
- 리허설을 다시 실행합니다.
- 모든 셀렉터가 통과할 때만 진행합니다.
3단계: 녹화
발견 및 리허설이 통과한 후에만 녹화를 만듭니다.
녹화 원칙
1. 스토리텔링 흐름
비디오를 이야기로 계획하세요. 사용자가 지정한 순서를 따르거나 다음 기본 순서를 사용하세요:
- 진입: 로그인하거나 시작점으로 이동합니다.
- 컨텍스트: 주변을 둘러보며 시청자가 방향을 잡을 수 있도록 합니다.
- 액션: 주요 워크플로우 단계를 수행합니다.
- 변형: 설정, 테마 또는 현지화와 같은 보조 기능을 보여줍니다.
- 결과: 결과, 확인 또는 새 상태를 보여줍니다.
2. 페이싱
- 로그인 후:
4초 - 탐색 후:
3초 - 버튼 클릭 후:
2초 - 주요 단계 사이:
1.5-2초 - 최종 작업 후:
3초 - 입력 지연: 문자당
25-40ms
3. 커서 오버레이
마우스 움직임을 따라가는 SVG 화살표 커서를 주입합니다:
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)를 호출하세요.
4. 마우스 움직임
절대 커서를 순간이동시키지 마세요. 클릭하기 전에 대상으로 이동하세요:
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(`경고: moveAndClick 건너뜀 - "${label}"을(를) 찾을 수 없음`);
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(`경고: moveAndClick 실패 - "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
모든 호출에는 디버깅을 위한 설명적인 label을 포함하세요.
5. 입력
즉시 채우지 않고 눈에 보이게 입력하세요:
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(`경고: typeSlowly 건너뜀 - "${label}"을(를) 찾을 수 없음`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. 스크롤
점프 대신 부드러운 스크롤을 사용하세요:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. 대시보드 패닝
대시보드 또는 개요 페이지를 표시할 때 주요 요소 위로 커서를 이동하세요:
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(`경고: panElements 요소 ${i} 건너뜀 (셀렉터: "${selector}"): ${e.message}`);
}
}
}
8. 자막
뷰포트 하단에 자막 막대를 주입합니다:
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);
}
탐색할 때마다 injectCursor(page)와 함께 injectSubtitleBar(page)를 호출하세요.
사용 패턴:
await showSubtitle(page, '1단계 - 로그인 중');
await showSubtitle(page, '2단계 - 대시보드 개요');
await showSubtitle(page, '');
지침:
- 자막 텍스트는 짧게, 이상적으로는 60자 미만으로 유지하세요.
- 일관성을 위해
N단계 - 액션형식을 사용하세요. - UI가 스스로 말할 수 있는 긴 일시 중지 동안에는 자막을 지우세요.
스크립트 템플릿
'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');
// injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible, 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();
// 흐름을 탐색하고 각 셀렉터에 대해 ensureVisible을 실행합니다.
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, '1단계 - 로그인 중');
// 로그인 액션
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, '2단계 - 대시보드 개요');
// 대시보드 패닝
await showSubtitle(page, '3단계 - 주요 워크플로우');
// 액션 시퀀스
await showSubtitle(page, '4단계 - 결과');
// 최종 결과 표시
await showSubtitle(page, '');
} catch (err) {
console.error('데모 오류:', 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('비디오 저장됨:', dest);
} catch (e) {
console.error('오류: 비디오 복사 실패:', e.message);
console.error(' 원본:', src);
console.error(' 대상:', dest);
}
}
await browser.close();
}
})();
사용법:
# 2단계: 리허설
node demo-script.cjs --rehearse
# 3단계: 녹화
node demo-script.cjs
녹화 전 체크리스트
- 발견 단계 완료
- 리허설 통과, 모든 셀렉터 정상
- 헤드리스 모드 활성화
- 해상도
1280x720으로 설정 - 모든 탐색 후 커서 및 자막 오버레이 재주입
- 주요 전환 시
showSubtitle(page, 'N단계 - ...')사용 - 모든 클릭에 설명적인 레이블과 함께
moveAndClick사용 - 눈에 보이는 입력에
typeSlowly사용 - 조용한 캐치 없음; 오류는 명시적으로 기록
- 인젝션, 리허설 및 이동/클릭 헬퍼가 최종 스크립트에 포함됨
- 출력 파일 이름이 기능과 일치 (예:
demo-purchase-request.webm)
참고
이 문서를 영어 원본과 함께 참조하세요. 기술 용어 및 코드는 번역되지 않습니다.