Spaces:
Runtime error
Runtime error
| """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() | |