자동화 커버리지를 넓히다가 이제 AI Assistant 의 차례가 됐다
일반적인 UI 검증과 달리 AI 챗봇은 응답이 언제 끝나는지 알 수 없다
일반 UI: 버튼 선택 → 모달 열림 (100ms)
AI 챗봇: 메시지 전송 → 응답 스트리밍 → 완료 (2초~30초)
wait_for_timeout(5000) 으로 때우면 빠른 응답은 시간 낭비가 되고, 느린 응답은 타임아웃이다
문제: 응답 완료 시점을 어떻게 아는가
AI 챗봇의 응답은 스트리밍으로 한 글자씩 나온다
응답 중간에 텍스트를 읽으면 불완전한 내용이 캡처된다
시도한 접근법들
1. wait_for_timeout(10000)
→ 빠른 응답도 10초 대기, 느린 응답은 실패
2. 텍스트 길이 변화 감시 (polling)
→ 스트리밍 중 잠깐 멈추는 구간을 완료로 오인
3. networkidle 대기
→ SSE 스트리밍은 연결이 유지되므로 networkidle 이 안 됨
해결: UI 가 알려주는 완료 신호를 찾는다
MCP 의 browser_snapshot 으로 채팅 UI 를 분석했다
응답이 완료되면 "Copy message" 버튼이 나타난다
응답 스트리밍 중: 텍스트만 표시
응답 완료 후: 텍스트 + [Copy message] 버튼 등장
이 버튼의 등장이 곧 응답 완료 신호다
응답 완료 감지
def is_response_visible(self, timeout_ms: int = 30000) -> bool:
"""응답 완료 대기 — Copy message 버튼 등장을 신호로 사용"""
try:
copy_btn = self.driver.page.get_by_role(
"button", name="Copy message"
)
copy_btn.wait_for(state="visible", timeout=timeout_ms)
return True
except PlaywrightTimeoutError:
return False
고정 대기 시간이 아니라 UI 상태 변화 를 기다린다
응답이 2초면 2초만, 25초면 25초만 기다린다
최대 30초를 넘기면 타임아웃으로 실패 처리한다
응답 텍스트 추출
def get_response_text(self) -> str:
"""마지막 응답의 텍스트를 추출"""
copy_btn = self.driver.page.get_by_role(
"button", name="Copy message"
).last # 여러 응답 중 마지막
# Copy 버튼의 부모 컨테이너 = 응답 메시지 블록
container = copy_btn.locator("..")
paragraphs = container.get_by_role("paragraph")
text_list = paragraphs.all_text_contents()
response_text = "\n".join(text_list)
return response_text
핵심은 Copy 버튼과 응답 텍스트가 같은 컨테이너에 있다 는 점이다
Copy 버튼을 찾으면 부모(..)로 올라가서 응답 전체를 가져올 수 있다
채팅 페이지 초기 상태 처리
AI Assistant 페이지는 마지막으로 열었던 채팅을 자동으로 보여준다
새 채팅 화면을 보장하려면 초기화가 필요하다
def _ensure_new_chat_screen(self) -> None:
"""새 채팅 화면 보장"""
# 인사말 키워드가 보이면 이미 새 채팅
try:
heading = self.driver.page.locator("h1").filter(
has_text=self.locators.GREETING_KEYWORD
)
heading.wait_for(state="visible", timeout=3000)
return
except PlaywrightTimeoutError:
pass
# 이전 채팅이 열려 있으면 새 채팅 버튼 선택
new_chat_btn = self.driver.page.get_by_role(
"button", name=self.locators.NEW_CHAT_BUTTON_NAME
)
new_chat_btn.click()
self.driver.page.wait_for_load_state("networkidle")
오버레이 처리
AI Assistant 페이지에 진입하면 2가지 모달이 겹칠 수 있다
def _dismiss_overlays(self) -> None:
"""페이지 진입 시 방해 오버레이 닫기"""
# 1. Covalent Feature Release 다이얼로그
try:
close_btn = self.driver.page.get_by_role(
"button", name="닫기"
)
if close_btn.is_visible():
close_btn.click()
except PlaywrightTimeoutError:
pass
# 2. 1:1 상담 모달 (z-index 100003)
try:
modal = self.driver.page.locator(
"[class*='z-[100003]']"
).last # dim layer 제외, 모달 본체만
if modal.is_visible():
close_btn = modal.locator("button").first
close_btn.click()
except PlaywrightTimeoutError:
pass
채팅 목록 카운트
def get_chat_list_count(self) -> int:
"""채팅 목록에 표시된 항목 수"""
chat_label = self.driver.page.get_by_text("채팅 목록")
parent = chat_label.locator("..")
links = parent.get_by_role("link")
count = links.count()
return count
"채팅 목록" 레이블의 부모에서 role="link" 를 센다
채팅 항목이 <a> 태그로 렌더링되므로 link role 로 잡을 수 있다
시나리오 설계
@smoke
Scenario: ai_assistant_01 AI Assistant 페이지 초기 로드
Given AI Assistant 페이지에 진입한다
Then 인사 메시지가 표시된다
And 새 채팅 버튼이 표시된다
And 프롬프트 탭이 표시된다
Scenario: ai_assistant_02 AI Assistant 에게 질문한다
Given AI Assistant 페이지에 진입한다
When "안녕하세요" 메시지를 전송한다
Then AI 응답이 표시된다
첫 번째 시나리오에 @smoke 를 붙였다
AI Assistant 페이지가 로드되는지만 매일 확인하면 충분하다
질의-응답 시나리오는 LLM 응답 시간 변동이 크므로 일일 Smoke 에는 넣지 않았다
follow-up 시나리오의 함정
위 is_response_visible 메서드는 첫 응답에 한해서만 안전하다
같은 채팅에서 두 번째 질문을 보내면 함정이 있다
이전 응답의 Copy 버튼이 이미 visible 상태로 남아 있어서, 새 응답이 도착하기도 전에 wait_for(state="visible") 가 즉시 통과한다
When "첫 질문" 메시지를 전송한다
Then AI 응답이 표시된다 # ✅ 정상 통과
When "두 번째 질문" 메시지를 전송한다 # follow-up
Then AI 응답이 표시된다 # ⚠️ 이전 응답 Copy 버튼이 잡혀 즉시 통과
해결은 "전송 전 Copy 버튼 개수 기록 → nth(이전 개수) 대기" 패턴이다
자세한 구현은 다음 편에서 다룬다
핵심 정리
| 패턴 | 설명 |
|---|---|
| Copy 버튼 등장 대기 | 응답 완료의 명확한 UI 신호 |
| 부모 컨테이너 탐색 | Copy 버튼 → 부모 → paragraph 수집 |
| 새 채팅 보장 | 인사말 h1 확인 → 없으면 새 채팅 버튼 선택 |
| 오버레이 순차 닫기 | Feature Release → 1:1 상담 순서 |
AI 챗봇 E2E 의 핵심은 "응답 완료를 타이머가 아닌 UI 상태로 감지하는 것" 이다
고정 대기 시간은 항상 너무 짧거나 너무 길다
UI 가 제공하는 완료 신호를 찾으면 빠른 응답에도 느린 응답에도 정확하게 대응할 수 있다
'TIL > Playwright' 카테고리의 다른 글
| [TIL][Playwright] 비동기 작업의 완료를 동기적으로 기다리지 않기 (0) | 2026.07.14 |
|---|---|
| [TIL][Playwright] AI 챗봇 응답 감지 #2 — follow-up 함정과 nth 인덱스 (0) | 2026.06.21 |
| [TIL][Playwright] expect vs wait_for — disabled 버튼 때문에 30초 날린 이야기 (0) | 2026.06.07 |
| [TIL][Claude] GitHub Actions 에서 Playwright E2E 매일 자동 실행하기 (0) | 2026.05.17 |
| [TIL][Playwright] SVG 요소는 선택으로 검증하면 안 된다 — DOM 구조 검증 (0) | 2026.03.22 |