jun0-ds
deploy: HF Spaces 배포
2e03d75
"""QA 자동 테스트 — Playwright로 전체 플레이 흐름 스크린샷 촬영
테스트 범위:
1. 웰컴 → 성별 선택 → 첫만남 생성
2. 캐릭터 선택 (첫 선택지 에러 재현 확인)
3. 스토리 전체 진행 (Stage 1~6, 모든 턴)
4. 매칭 선호도 → 결과 발표
5. 매칭 시작 → 후보 탐색 → 결과 공개
6. 채팅 기능 테스트
"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
SCREENSHOTS = Path(__file__).parent / "qa_screenshots"
SCREENSHOTS.mkdir(exist_ok=True)
URL = "http://127.0.0.1:7860/"
GPT_TIMEOUT = 90_000
issues = []
def shot(page, name):
path = SCREENSHOTS / f"{name}.png"
page.screenshot(path=str(path), full_page=False)
print(f" [shot] {name}")
def check_error(page, context=""):
"""Gradio 에러 토스트 확인."""
error_el = page.query_selector(".error, .toast-error, [class*='error']")
if error_el and error_el.is_visible():
text = error_el.text_content() or ""
if "Error" in text or "에러" in text or "오류" in text:
msg = f"에러 감지 [{context}]: {text[:100]}"
issues.append(msg)
print(f" !! {msg}")
return True
return False
def wait_not_processing(page, timeout_ms=GPT_TIMEOUT):
"""Gradio processing 표시가 사라질 때까지 대기."""
page.wait_for_function(
"""() => {
const prog = document.querySelector('.progress-text, .eta-bar');
if (prog && prog.offsetParent !== null) return false;
const gen = document.querySelector('.generating');
if (gen) return false;
return true;
}""",
timeout=timeout_ms,
)
time.sleep(0.8)
def get_choice_buttons(page):
"""현재 보이는 A./B./C./D. 선택지 버튼 리스트 반환."""
buttons = page.query_selector_all("button")
choices = []
for btn in buttons:
text = (btn.text_content() or "").strip()
if btn.is_visible() and (
text.startswith("A.") or text.startswith("B.")
or text.startswith("C.") or text.startswith("D.")
):
choices.append((text, btn))
return choices
def has_restart_button(page):
buttons = page.query_selector_all("button")
for btn in buttons:
text = (btn.text_content() or "").strip()
if "다시 플레이" in text and btn.is_visible():
return True
return False
def has_chat_input(page):
"""대화 입력창이 보이는지 확인."""
textarea = page.query_selector("textarea")
return textarea is not None and textarea.is_visible()
def has_chat_exit(page):
"""'목록으로' 버튼이 보이는지 확인."""
return click_button_containing(page, "목록으로", dry_run=True)
def click_button_containing(page, text_match, dry_run=False):
buttons = page.query_selector_all("button")
for btn in buttons:
text = (btn.text_content() or "").strip()
if text_match in text and btn.is_visible():
if not dry_run:
btn.click()
return text
return None
def get_chatbox_text(page):
"""chatbox 내부 텍스트를 반환 (콘텐츠 변경 감지용)."""
el = page.query_selector("#chatbox")
if el:
return (el.text_content() or "")[:200]
return ""
def wait_for_new_choices(page, old_texts, timeout_ms=GPT_TIMEOUT):
"""이전 선택지와 다른 새 선택지가 나타나거나, 다시 플레이 버튼이 나타날 때까지 대기."""
old_set = set(old_texts)
old_chatbox = get_chatbox_text(page)
start = time.time()
while (time.time() - start) * 1000 < timeout_ms:
try:
wait_not_processing(page, timeout_ms=5000)
except Exception:
pass
if has_restart_button(page):
return "restart"
if has_chat_input(page):
return "chat"
choices = get_choice_buttons(page)
new_texts = {t for t, _ in choices}
# 버튼 텍스트가 변경되었거나, 같은 텍스트여도 chatbox 콘텐츠가 변경됨
if choices and new_texts != old_set:
return "choices"
if choices and get_chatbox_text(page) != old_chatbox:
return "content_changed"
time.sleep(1)
return "timeout"
def run_qa():
global issues
issues = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 640, "height": 900})
page = context.new_page()
# 콘솔 에러 수집
console_errors = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.goto(URL, wait_until="load", timeout=30000)
# Gradio 6.x SSR 로딩 대기 — "시작하기" 버튼이 나타날 때까지
print(" Gradio 로딩 대기...")
for _ in range(30):
if click_button_containing(page, "시작하기", dry_run=True):
break
time.sleep(1)
else:
shot(page, "00_loading_stuck")
issues.append("Gradio 로딩 타임아웃 (시작하기 버튼 없음)")
print(" !! Gradio 로딩 타임아웃")
browser.close()
_print_summary(1, 0, console_errors)
return
time.sleep(1)
step = 1
# ═══════════════════════════════════════
# Phase 1: 웰컴 → 성별 선택 → 첫만남
# ═══════════════════════════════════════
print("=" * 50)
print("Phase 1: 웰컴 → 성별 선택 → 첫만남")
print("=" * 50)
shot(page, f"{step:02d}_welcome")
print(f"[{step}] 웰컴 화면 OK")
step += 1
# 시작하기 버튼
result = click_button_containing(page, "시작하기")
if not result:
issues.append("시작하기 버튼 없음")
print(" !! 시작하기 버튼을 찾을 수 없음")
browser.close()
return
time.sleep(2)
shot(page, f"{step:02d}_gender_select")
print(f"[{step}] 성별 선택 화면")
step += 1
# 성별 선택: 기본값(여성/남성) 그대로 사용
# gender_confirm_btn ("💜 시작하기") 클릭
result = click_button_containing(page, "시작하기")
if not result:
issues.append("성별 확인 버튼 없음")
browser.close()
return
print(f" 성별 확인: {result}")
print(f" GPT 첫만남 생성 대기...")
# 첫만남 완료 대기
result = wait_for_new_choices(page, [], timeout_ms=GPT_TIMEOUT)
time.sleep(1)
check_error(page, "첫만남 생성 후")
shot(page, f"{step:02d}_first_meetings")
print(f"[{step}] 첫만남 결과: {result}")
step += 1
if result == "timeout":
issues.append("첫만남 생성 타임아웃")
print(" !! TIMEOUT")
browser.close()
_print_summary(step, 0, console_errors)
return
# ═══════════════════════════════════════
# Phase 2: 캐릭터 선택 (첫 선택지 — 이전 에러 포인트)
# ═══════════════════════════════════════
print("\n" + "=" * 50)
print("Phase 2: 캐릭터 선택 (이전 에러 포인트)")
print("=" * 50)
choices = get_choice_buttons(page)
if not choices:
issues.append("캐릭터 선택 버튼 없음")
browser.close()
_print_summary(step, 0, console_errors)
return
char_texts = [t for t, _ in choices]
print(f" 캐릭터: {char_texts}")
# A 선택
old_texts = [t for t, _ in choices]
choices[0][1].click()
print(f" -> 선택: {char_texts[0]}")
result = wait_for_new_choices(page, old_texts, timeout_ms=GPT_TIMEOUT)
time.sleep(1)
has_err = check_error(page, "캐릭터 선택 후")
shot(page, f"{step:02d}_after_char_select")
print(f"[{step}] 캐릭터 선택 후: {result} (에러: {'Y' if has_err else 'N'})")
step += 1
if result == "timeout":
issues.append("캐릭터 선택 후 타임아웃")
browser.close()
_print_summary(step, 0, console_errors)
return
# ═══════════════════════════════════════
# Phase 3: 스토리 루프 (모든 턴)
# ═══════════════════════════════════════
print("\n" + "=" * 50)
print("Phase 3: 스토리 루프")
print("=" * 50)
turn = 0
max_turns = 40
choice_cycle = [0, 1, 2, 0, 1, 0, 3, 0]
while turn < max_turns:
# 결과 화면 또는 매칭 체크
if has_restart_button(page):
print(f" -> 결과 화면 도달 (turn {turn})")
break
choices = get_choice_buttons(page)
if not choices:
time.sleep(2)
turn += 1
continue
# 선택
idx = choice_cycle[turn % len(choice_cycle)]
if idx >= len(choices):
idx = 0
old_texts = [t for t, _ in choices]
chosen_text = choices[idx][0]
choices[idx][1].click()
turn += 1
print(f" Turn {turn}: {chosen_text[:60]}")
result = wait_for_new_choices(page, old_texts, timeout_ms=GPT_TIMEOUT)
check_error(page, f"Turn {turn}")
if result == "timeout":
shot(page, f"{step:02d}_timeout_turn{turn}")
issues.append(f"Turn {turn} 타임아웃")
print(f" !! TIMEOUT at turn {turn}")
break
# 5턴마다 스크린샷
if turn % 5 == 0 or result == "restart":
shot(page, f"{step:02d}_turn{turn}")
step += 1
print(f" 스토리 루프 완료: {turn}턴")
# ═══════════════════════════════════════
# Phase 4: 결과 발표 → 매칭 선호도
# ═══════════════════════════════════════
print("\n" + "=" * 50)
print("Phase 4: 결과 발표 → 매칭")
print("=" * 50)
# 결과 화면 스크린샷
shot(page, f"{step:02d}_results_screen")
step += 1
# 결과 화면 스크롤
page.evaluate("document.getElementById('chatbox')?.scrollTo(0, 0)")
time.sleep(0.5)
shot(page, f"{step:02d}_results_top")
step += 1
page.evaluate("el = document.getElementById('chatbox'); if(el) el.scrollTo(0, el.scrollHeight/3)")
time.sleep(0.5)
shot(page, f"{step:02d}_results_1third")
step += 1
page.evaluate("el = document.getElementById('chatbox'); if(el) el.scrollTo(0, el.scrollHeight*2/3)")
time.sleep(0.5)
shot(page, f"{step:02d}_results_2third")
step += 1
page.evaluate("el = document.getElementById('chatbox'); if(el) el.scrollTo(0, el.scrollHeight)")
time.sleep(0.5)
shot(page, f"{step:02d}_results_bottom")
step += 1
# 매칭 시작하기 버튼 확인 및 클릭
choices = get_choice_buttons(page)
matching_btn = None
for text, btn in choices:
if "매칭" in text:
matching_btn = (text, btn)
break
if matching_btn:
old_texts = [t for t, _ in choices]
matching_btn[1].click()
print(f" 매칭 시작: {matching_btn[0]}")
result = wait_for_new_choices(page, old_texts, timeout_ms=30000)
check_error(page, "매칭 시작 후")
shot(page, f"{step:02d}_match_browse_1")
step += 1
# ═══════════════════════════════════════
# Phase 5: 매칭 후보 탐색
# ═══════════════════════════════════════
print("\n" + "=" * 50)
print("Phase 5: 매칭 후보 탐색")
print("=" * 50)
browse_round = 0
max_browse = 6
while browse_round < max_browse:
choices = get_choice_buttons(page)
if not choices:
break
# 첫 번째와 세 번째는 "관심 있어요", 나머지는 "넘기기"
has_interest = any("관심" in t for t, _ in choices)
has_skip = any("넘기기" in t for t, _ in choices)
if not has_interest and not has_skip:
# 매칭 결과 화면으로 전환됨
print(f" 매칭 결과 공개 화면")
break
# 교대로 선택/넘기기
if browse_round % 3 == 0:
target = "관심"
else:
target = "넘기기"
clicked = None
for text, btn in choices:
if target in text:
old_texts = [t for t, _ in choices]
btn.click()
clicked = text
break
if not clicked and choices:
old_texts = [t for t, _ in choices]
choices[0][1].click()
clicked = choices[0][0]
browse_round += 1
print(f" 후보 {browse_round}: {clicked}")
result = wait_for_new_choices(page, old_texts, timeout_ms=30000)
check_error(page, f"후보 {browse_round}")
if browse_round % 2 == 0:
shot(page, f"{step:02d}_match_browse_{browse_round}")
step += 1
# ═══════════════════════════════════════
# Phase 6: 매칭 결과 공개 + 채팅
# ═══════════════════════════════════════
print("\n" + "=" * 50)
print("Phase 6: 매칭 결과 공개 + 채팅")
print("=" * 50)
shot(page, f"{step:02d}_match_reveal")
step += 1
# 대화 상대 선택 (첫 번째 버튼)
choices = get_choice_buttons(page)
if choices:
chat_target = choices[0]
old_texts = [t for t, _ in choices]
chat_target[1].click()
print(f" 대화 상대 선택: {chat_target[0]}")
result = wait_for_new_choices(page, old_texts, timeout_ms=30000)
check_error(page, "대화 상대 선택 후")
time.sleep(1)
# 채팅 화면 확인
if has_chat_input(page):
print(f" 채팅 입력창 확인 OK")
shot(page, f"{step:02d}_chat_screen")
step += 1
# 메시지 보내기 테스트
textarea = page.query_selector("textarea")
if textarea:
textarea.fill("안녕하세요! 반갑습니다 ^^")
time.sleep(0.5)
send_btn = click_button_containing(page, "보내기")
if send_btn:
print(f" 메시지 전송: '안녕하세요! 반갑습니다 ^^'")
time.sleep(3)
try:
wait_not_processing(page, timeout_ms=30000)
except Exception:
pass
check_error(page, "메시지 전송 후")
shot(page, f"{step:02d}_chat_response")
step += 1
# 두 번째 메시지
textarea = page.query_selector("textarea")
if textarea:
textarea.fill("오늘 날씨가 좋네요")
time.sleep(0.3)
click_button_containing(page, "보내기")
print(f" 메시지 전송: '오늘 날씨가 좋네요'")
time.sleep(3)
try:
wait_not_processing(page, timeout_ms=30000)
except Exception:
pass
check_error(page, "두번째 메시지 후")
shot(page, f"{step:02d}_chat_response2")
step += 1
# 목록으로 돌아가기
exit_result = click_button_containing(page, "목록으로")
if exit_result:
print(f" 대화 종료: {exit_result}")
time.sleep(2)
try:
wait_not_processing(page, timeout_ms=10000)
except Exception:
pass
check_error(page, "대화 종료 후")
shot(page, f"{step:02d}_back_to_reveal")
step += 1
else:
issues.append("'목록으로' 버튼 없음")
else:
issues.append("채팅 입력창이 나타나지 않음")
shot(page, f"{step:02d}_no_chat_input")
step += 1
else:
issues.append("매칭 결과에서 대화 상대 버튼 없음")
else:
issues.append("매칭 시작하기 버튼 없음")
# ═══════════════════════════════════════
# 종합 보고
# ═══════════════════════════════════════
_print_summary(step, turn, console_errors)
browser.close()
def _print_summary(step, turn, console_errors):
print(f"\n{'=' * 60}")
print(f"QA 종합 보고")
print(f"{'=' * 60}")
print(f" 총 스크린샷: {step - 1}장")
print(f" 총 턴 수: {turn}")
if console_errors:
print(f"\n 콘솔 에러 ({len(console_errors)}건):")
for err in console_errors[:10]:
print(f" - {err[:120]}")
if issues:
print(f"\n 이슈 ({len(issues)}건):")
for iss in issues:
print(f" ❌ {iss}")
else:
print(f"\n ✅ 이슈 없음! 전체 플로우 정상 동작.")
print(f"{'=' * 60}")
if __name__ == "__main__":
run_qa()