TIL/Playwright

[TIL][Playwright] SVG 요소는 선택으로 검증하면 안 된다 — DOM 구조 검증

아람2 2026. 3. 22. 01:28
반응형

 

HyperDesign 에서 Warhead 원자가 선택 보호되어 있는지 검증해야 했다

처음엔 선택해서 반응이 없으면 보호가 정상이라고 판단했다

틀렸다

force=True 가 보호를 우회하기 때문이다


상황: Covalent HyperDesign 의 Warhead 보호

약물 설계 도구에서 분자 구조를 수정할 수 있다

그런데 Warhead (반응기) 부분은 수정하면 안 된다

UI 에서 Warhead 원자를 선택해도 반응되지 않아야 한다

이걸 E2E 자동화로 검증해야 한다


첫 번째 시도: 선택 기반 검증 (실패)

# ❌ Bad — 선택해서 반응 없으면 보호 정상이라고 판단
def test_warhead_protection(page):
    warhead_atom = page.locator(".warhead-atom").first
    warhead_atom.click()

    selected = page.locator(".selected-atom")
    if selected.count() == 0:
        is_protected = True
    else:
        is_protected = False

    assert is_protected, "Warhead 보호 실패"

문제가 2가지다

1. force=True 가 보호를 우회한다

Playwright 에서 요소가 선택 불가능하면 자동으로 재시도하다가 timeout 이 터진다

그래서 force=True 를 쓰고 싶은 유혹이 생긴다

warhead_atom.click(force=True)  # pointerEvents: none 을 무시한다

force=True 는 Playwright 의 actionability check 를 전부 건너뛴다

pointerEvents: none 으로 보호된 요소도 선택이 된다

보호가 정상인데 검증에서 선택이 되니까 검증이 실패한다

2. 선택 결과가 비결정적이다

선택 후 "선택 안 됨" 을 확인하는 건 네거티브 검증이다

반응이 늦은 건지 없는 건지 구분이 안 된다


MCP snapshot 으로 실제 DOM 구조 확인

코드만 보고 "Warhead 원자 = 노란색 ellipse" 라고 가정했다

MCP browser_snapshot 으로 실제 DOM 을 확인했다

발견 1: 모든 ellipse 에 data-fragment-color 속성이 있다 (Fragment 전용)
발견 2: data-fragment-color 가 없는 ellipse = 0개
발견 3: Warhead 원자는 ellipse 가 아니라 path 로 렌더링된다
Warhead: path[class^="bond-"], path[class^="atom-"]  (pointerEvents: none)
Fragment: ellipse[data-fragment-color]                (pointerEvents: auto)

코드만 봤으면 이 차이를 절대 못 찾았다


해결: DOM 구조 검증

선택하지 않고 DOM 자체를 검사한다

Warhead 영역에 선택 가능한 ellipse 가 0개인지 확인한다

로케이터 상수 (hyper_design_locators.py)

FRAGMENT_ATOM_SELECTOR = "ellipse"
FRAGMENT_COLOR_ATTR = "data-fragment-color"
WARHEAD_SELECTABLE_SELECTOR = "ellipse:not([data-fragment-color])"
WARHEAD_BOND_SELECTOR = 'path[class^="bond-"]'
WARHEAD_ATOM_LABEL_SELECTOR = 'path[class^="atom-"]'
HIGHLIGHT_ATTR = "data-highlight"
MIN_MOLECULE_SVG_SIZE = 100

Page Object 메서드 (hyper_design_page.py)

def get_warhead_selectable_count(self) -> int:
    """Warhead 선택 가능 ellipse 수 반환 (0이면 보호 정상, -1이면 SVG 없음)"""
    modal = self._get_modal_or_fail()
    svg = self._find_molecule_svg(modal)
    if svg is None:
        return -1
    non_fragment = svg.locator(self.locators.WARHEAD_SELECTABLE_SELECTOR)
    # "ellipse:not([data-fragment-color])" — Fragment 아닌 ellipse
    return non_fragment.count()


def get_total_fragment_ellipse_count(self) -> int:
    """Fragment ellipse 수 반환 (0보다 커야 정상)"""
    modal = self._get_modal_or_fail()
    svg = self._find_molecule_svg(modal)
    if svg is None:
        return 0
    selector = f"{self.locators.FRAGMENT_ATOM_SELECTOR}[{self.locators.FRAGMENT_COLOR_ATTR}]"
    # → "ellipse[data-fragment-color]"
    return svg.locator(selector).count()


def get_highlighted_fragment_count(self) -> int:
    """하이라이트된 요소 수 반환 (선택 후 선택 확인용)"""
    modal = self._get_modal_or_fail()
    svg = self._find_molecule_svg(modal)
    if svg is None:
        return 0
    return svg.locator(f"[{self.locators.HIGHLIGHT_ATTR}='true']").count()

SVG 탐색 헬퍼

SVG 태그가 페이지에 여러 개 있을 수 있다 (아이콘 등)

분자 구조 SVG 만 찾기 위해 bounding_box() 크기를 체크한다

def _find_molecule_svg(self, container) -> Locator | None:
    """컨테이너 내에서 분자 구조 SVG 찾기 (아이콘 제외)"""
    svg_candidates = container.locator(self.locators.MOLECULE_SVG_TAG)
    # MOLECULE_SVG_TAG = "svg"

    for attempt in range(2):
        for i in range(svg_candidates.count()):
            try:
                box = svg_candidates.nth(i).bounding_box()
                if box and box["width"] > self.locators.MIN_MOLECULE_SVG_SIZE:
                    # MIN_MOLECULE_SVG_SIZE = 100
                    return svg_candidates.nth(i)
            except PlaywrightTimeoutError:
                continue
        # 첫 시도 실패 시 1초 대기 후 재시도
        self.driver.page.wait_for_timeout(1000)

    return None

Step 정의 (test_hyper_design_steps.py)

@then("Warhead 영역에 선택 가능한 요소가 없다")
def verify_warhead_not_selectable(hyper_design_page):
    _assert_cond(
        lambda: hyper_design_page.get_warhead_selectable_count() == 0,
        success_msg="Warhead 보호 정상",
        fail_msg="Warhead 영역에 선택 가능 요소 존재",
        screenshot_name="warhead_protection",
    )


@then("Fragment ellipse가 존재한다")
def verify_fragment_exists(hyper_design_page):
    _assert_cond(
        lambda: hyper_design_page.get_total_fragment_ellipse_count() > 0,
        success_msg="Fragment ellipse 확인",
        fail_msg="Fragment ellipse 없음",
        screenshot_name="fragment_ellipse",
    )


@then("Fragment가 정상 선택된다")
def verify_fragment_selected(hyper_design_page):
    _assert_cond(
        lambda: hyper_design_page.get_highlighted_fragment_count() > 0,
        success_msg="Fragment 선택 확인",
        fail_msg="Fragment highlight 없음",
        screenshot_name="fragment_selected",
    )

_assert_cond 는 lambda 조건 + 성공/실패 메시지 + 스크린샷명을 받는 assertion 헬퍼다


추가 문제: evaluate() hang

4워커 병렬 실행 시 evaluate() 가 무한 블로킹되는 문제도 있었다

Chromium 리소스 경합이 원인이었다

# Before — timeout 없음 → 무한 블로킹
frag_color = atom.evaluate(f"e => e.getAttribute('{color_attr}')")

# After — 5초 타임아웃 추가
frag_color = atom.evaluate(f"e => e.getAttribute('{color_attr}')", timeout=5000)

근본적으로는 4워커 동시 실행 → 2+2 순차 배치로 전환하여 Chromium 2개만 동시 실행되게 했다


언제 DOM 구조 검증을 쓰면 좋은가

선택 기반이 맞는 경우

- 버튼 선택 후 화면 전환 (포지티브 검증)
- 입력 필드에 값 입력 후 결과 확인
- 드롭다운 선택 후 선택값 검증

DOM 구조 검증이 맞는 경우

- 요소가 선택 불가능한지 확인 (네거티브 검증)
- CSS 속성으로 상태가 결정되는 경우 (disabled, pointerEvents)
- SVG 내부 요소의 속성 검증
- 렌더링 구조 자체가 검증 대상인 경우

핵심 정리

방식 적합한 상황 부적합한 상황
선택 기반 버튼 동작 확인 선택 불가 검증
DOM 구조 속성/상태 검증 사용자 인터랙션 흐름

 

SVG 검증에서 기억할 것

  1. force=True 는 보호 메커니즘을 우회한다 — 검증에서 쓰면 거짓 결과
  2. 네거티브 검증은 DOM 속성을 직접 검사하는 게 안정적이다
  3. 가정하지 말고 MCP snapshot 으로 실제 DOM 을 확인해라
  4. SVG 탐색 시 아이콘 SVG 를 제외하려면 bounding_box 크기 체크
반응형