| name | ui-demo |
| description | Playwright를 사용하여 세련된 UI 데모 비디오를 녹화합니다. 사용자가 웹 애플리케이션의 데모, 연습, 화면 녹화 또는 튜토리얼 비디오를 만들고 싶을 때 사용합니다. 눈에 띄는 커서, 자연스러운 진행 속도, 전문적인 느낌이 있는 WebM 비디오를 생성합니다. |
| origin | ECC |
UI 데모 비디오 레코더
Playwright의 비디오 녹화 기능과 주입된 커서 오버레이, 자연스러운 진행 속도, 스토리텔링 흐름을 사용하여 웹 애플리케이션의 세련된 데모 비디오를 녹화합니다.
사용 시점
- 사용자가 "데모 비디오", "화면 녹화", "연습(walkthrough)" 또는 "튜토리얼"을 요청할 때
- 사용자가 기능이나 워크플로를 시각적으로 보여주고 싶을 때
- 문서화, 온보딩 또는 이해관계자 프레젠테이션용 비디오가 필요할 때
3단계 프로세스
모든 데모는 **발견(Discover) -> 리허설(Rehearse) -> 녹화(Record)**의 3단계를 거칩니다. 절대로 곧바로 녹화 단계로 건너뛰지 마세요.
1단계: 발견 (Discover)
스크립트를 작성하기 전에 대상 페이지를 탐색하여 실제로 무엇이 있는지 이해합니다.
이유
보지 않은 것을 스크립팅할 수는 없습니다. 필드가 <textarea>가 아닌 <input>일 수 있고, 드롭다운이 <select>가 아닌 사용자 정의 컴포넌트일 수 있으며, 댓글 상자가 @mentions나 #tags를 지원할 수 있습니다. 가정은 녹화를 소리 없이 망칩니다.
방법
흐름의 각 페이지로 이동하여 대화형 요소를 덤프합니다:
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)과 텍스트를 모두 덤프합니다. 자리 표시자(placeholder)는 종종 비어 있지 않은 것처럼 보이는
value="0" 또는 value=""를 갖습니다. Array.from(el.options).map(o => ({ value: o.value, text: o.text }))를 사용하세요. 텍스트에 "Select"가 포함되거나 값이 "0"인 옵션은 건너뜁니다.
- 리치 텍스트: 댓글 상자가
@mentions, #tags, 마크다운 또는 이모지를 지원하나요? 자리 표시자 텍스트를 확인하세요.
- 필수 필드: 폼 제출을 차단하는 필드는 무엇인가요?
required, 레이블의 *를 확인하고 빈 상태로 제출하여 유효성 검사 오류를 확인하세요.
- 동적 콘텐츠: 다른 필드를 채운 후 나타나는 필드가 있나요?
- 버튼 레이블:
"Submit", "Submit Request", "Send"와 같은 정확한 텍스트.
- 테이블 열 헤더: 테이블 기반 모달의 경우, 모든 숫자 입력이 같은 의미를 갖는다고 가정하는 대신 각
input[type="number"]를 해당 열 헤더에 매핑합니다.
출력 결과
스크립트에 올바른 선택자(selector)를 작성하는 데 사용되는 각 페이지의 필드 맵입니다. 예시:
/purchase-requests/new:
- Budget Code: <select> (페이지의 첫 번째 select, 4개 옵션)
- Desired Delivery: <input type="date">
- Context: <textarea> (input 아님)
- BOM table: span.cursor-pointer -> input 패턴을 가진 인라인 편집 셀
- Submit: <button> text="Submit"
/purchase-requests/N (상세):
- Comment: <input placeholder="Type a message..."> @user 및 #PR 태그 지원
- Send: <button> text="Send" (입력에 내용이 있을 때까지 비활성화됨)
2단계: 리허설 (Rehearse)
녹화 없이 모든 단계를 실행해 봅니다. 모든 선택자가 해결되는지 확인합니다.
이유
조용한 선택자 실패가 데모 녹화가 깨지는 주요 원인입니다. 리허설은 녹화를 낭비하기 전에 이를 잡아냅니다.
방법
로그를 남기고 크게 실패하는 래퍼 함수인 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 = `REHEARSAL FAIL: "${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();
});
.( + found);
;
}
.();
;
}
리허설 스크립트 구조
const steps = [
{ label: '로그인 이메일 필드', selector: '#email' },
{ label: '로그인 제출', selector: 'button[type="submit"]' },
{ label: 'New Request 버튼', selector: 'button:has-text("New Request")' },
{ label: 'Budget Code 선택', selector: 'select' },
{ label: '배송일', selector: 'input[type="date"]:visible' },
{ label: '설명 필드', selector: 'textarea:visible' },
{ label: 'Add Item 버튼', selector: 'button:has-text("Add Item")' },
{ label: 'Submit 버튼', selector: 'button:has-text("Submit")' },
];
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.();
리허설 실패 시
- 덤프된 표시 요소를 읽습니다.
- 올바른 선택자를 찾습니다.
- 스크립트를 업데이트합니다.
- 리허설을 다시 실행합니다.
- 모든 선택자가 통과될 때만 진행합니다.
3단계: 녹화 (Record)
발견 및 리허설을 모두 통과한 후에만 녹화를 생성합니다.
녹화 원칙
1. 스토리텔링 흐름
비디오를 하나의 이야기로 계획합니다. 사용자가 지정한 순서를 따르거나 다음 기본값을 사용합니다:
- 시작: 로그인하거나 시작 지점으로 이동
- 컨텍스트: 시청자가 방향을 잡을 수 있도록 주변을 둘러보기
- 동작: 주요 워크플로 단계 수행
- 변형: 설정, 테마 또는 현지화와 같은 보조 기능 보여주기
- 결과: 결과, 확인 또는 새로운 상태 보여주기
2. 속도 (Pacing)
- 로그인 후:
4s
- 탐색 후:
3s
- 버튼 클릭 후:
2s
- 주요 단계 사이:
1.5-2s
- 마지막 동작 후:
3s
- 타이핑 지연: 문자당
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);
} (e) {
.();
;
}
page.(postClickDelay);
;
}
디버깅을 위해 모든 호출에는 설명적인 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.. = ;
} {
bar.. = ;
}
}, text);
(text) page.();
}
모든 탐색 후 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');
(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();
browser.();
;
}
context = browser.({
: { : , : { : , : } },
: { : , : }
});
page = context.();
{
(page);
(page);
(page, );
page.();
(page);
(page);
(page, );
(page, );
(page, );
(page, );
} (err) {
.(, err.);
} {
context.();
video = page.();
(video) {
src = video.();
dest = path.(, );
{
fs.(src, dest);
.(, dest);
} (e) {
.(, e.);
.(, src);
.(, dest);
}
}
browser.();
}
})();
사용법:
node demo-script.cjs --rehearse
node demo-script.cjs
녹화 전 체크리스트
흔한 실수 (Common Pitfalls)
- 페이지 이동 후 커서가 사라짐 - 다시 주입하세요.
- 비디오가 너무 빠름 - 일시 중지를 추가하세요.
- 커서가 화살표가 아닌 점임 - SVG 오버레이를 사용하세요.
- 커서가 순간 이동함 - 클릭하기 전에 이동하세요.
- 선택(Select) 드롭다운이 이상해 보임 - 이동을 보여준 다음 옵션을 선택하세요.
- 모달이 너무 갑작스럽게 느껴짐 - 확인하기 전에 읽을 수 있는 시간(pause)을 추가하세요.
- 비디오 파일 경로가 무작위임 - 안정적인 출력 이름으로 복사하세요.
- 선택자 실패가 덮어짐 - 조용히 처리되는 catch 블록을 절대 사용하지 마세요.
- 필드 유형을 가정함 - 먼저 발견(discover) 단계를 거치세요.
- 기능을 가정함 - 스크립팅하기 전에 실제 UI를 검사하세요.
- 자리 표시자(placeholder) 선택 값이 진짜처럼 보임 -
"0" 및 "Select..."에 주의하세요.
- 팝업이 별도의 비디오를 생성함 - 팝업 페이지를 명시적으로 캡처하고 필요한 경우 나중에 병합하세요.