Puppetaria: 접근성 중심 Puppeteer 스크립트

요한베이
요한베이

Puppeteer 및 선택기에 대한 접근 방식

Puppeteer는 노드용 브라우저 자동화 라이브러리로, 간단한 최신 JavaScript API를 사용하여 브라우저를 제어할 수 있게 해줍니다.

물론 가장 눈에 띄는 브라우저 작업은 웹페이지 탐색입니다. 이 작업을 자동화하면 웹페이지와의 상호작용을 자동화하는 것과 같습니다.

Puppeteer에서는 문자열 기반 선택기를 사용하여 DOM 요소를 쿼리하고 요소에서 텍스트를 클릭하거나 입력하는 등의 작업을 실행하여 이를 수행할 수 있습니다. 예를 들어 developer.google.com을 열고 검색창을 찾은 다음 puppetaria를 검색하는 스크립트는 다음과 같습니다.

(async () => {
   const browser = await puppeteer.launch({ headless: false });
   const page = await browser.newPage();
   await page.goto('https://developers.google.com/', { waitUntil: 'load' });
   // Find the search box using a suitable CSS selector.
   const search = await page.$('devsite-search > form > div.devsite-search-container');
   // Click to expand search box and focus it.
   await search.click();
   // Enter search string and press Enter.
   await search.type('puppetaria');
   await search.press('Enter');
 })();

따라서 쿼리 선택기를 사용하여 요소를 식별하는 방법은 Puppeteer 환경의 중요한 부분입니다. 지금까지 Puppeteer의 선택기는 표현적으로 매우 강력하지만 스크립트에서 브라우저 상호작용을 유지하는 데 단점이 있을 수 있는 CSS 및 XPath 선택기로 제한되었습니다.

구문 선택자와 시맨틱 선택기

CSS 선택자는 기본적으로 구문적이므로 DOM에서 ID와 클래스 이름을 참조한다는 점에서 DOM 트리의 텍스트 표현의 내부 작동과 밀접하게 연결되어 있습니다. 따라서 웹 개발자가 페이지의 요소에 스타일을 수정하거나 추가하는 데 필수적인 도구를 제공하지만, 이 컨텍스트에서는 개발자가 페이지와 DOM 트리를 완전히 제어할 수 있습니다.

반면 Puppeteer 스크립트는 페이지의 외부 관찰자이므로 이 컨텍스트에서 CSS 선택자가 사용될 때 Puppeteer 스크립트로는 제어할 수 없는 페이지 구현 방식에 관한 숨겨진 가정을 도입합니다.

그 결과 이러한 스크립트는 불안정하고 소스 코드 변경에 취약할 수 있습니다. 예를 들어 <button>Submit</button> 노드가 body 요소의 세 번째 하위 요소로 포함된 웹 애플리케이션의 자동 테스트에 Puppeteer 스크립트를 사용한다고 가정해 보겠습니다. 테스트 사례의 스니펫 하나는 다음과 같을 수 있습니다.

const button = await page.$('body:nth-child(3)'); // problematic selector
await button.click();

여기에서는 'body:nth-child(3)' 선택기를 사용하여 제출 버튼을 찾지만 이 버전은 정확히 이 버전의 웹페이지와 밀접하게 연결되어 있습니다. 나중에 요소가 버튼 위에 추가되면 이 선택기가 더 이상 작동하지 않습니다.

이는 테스트 작성자에게 반가운 소식은 아닙니다. Puppeteer 사용자는 이미 이러한 변경사항에 견고한 선택기를 선택하려고 하고 있습니다. 이번 퀘스트에서 Puppetaria의 새로운 도구를 사용하면 됩니다.

Puppeteer는 이제 CSS 선택기를 사용하는 대신 접근성 트리 쿼리를 기반으로 하는 대체 쿼리 핸들러와 함께 제공됩니다. 여기서 기본적인 철학은 선택하려는 구체적인 요소가 변경되지 않았다면 상응하는 접근성 노드도 변경되지 않았어야 한다는 것입니다.

이러한 선택기의 이름을 'ARIA 선택기'라고 하며, 계산된 액세스 가능 이름 및 접근성 트리의 역할에 대한 쿼리를 지원합니다. CSS 선택자와 비교할 때 이러한 속성은 본질적으로 의미 체계입니다. DOM의 구문 속성이 아니라 스크린 리더와 같은 보조 기술을 통해 페이지가 관찰되는 방식에 대한 설명자와 연결되어 있습니다.

위의 테스트 스크립트 예시에서는 대신 aria/Submit[role="button"] 선택기를 사용하여 원하는 버튼을 선택할 수 있습니다. 여기서 Submit은 요소의 액세스 가능한 이름을 나타냅니다.

const button = await page.$('aria/Submit[role="button"]');
await button.click();

나중에 버튼의 텍스트 콘텐츠를 Submit에서 Done로 변경하기로 결정하면 테스트가 다시 실패하지만 이 경우 이는 바람직합니다. 버튼의 이름을 변경하면 시각적 표시나 DOM에서 구조화된 방식이 아닌 페이지의 콘텐츠가 변경됩니다. 테스트에서 이러한 변경사항을 경고하여 의도적인 변경인지 확인해야 합니다.

검색창을 사용하는 더 큰 예시로 돌아가 새로운 aria 핸들러를 활용하여 다음을 대체할 수 있습니다.

const search = await page.$('devsite-search > form > div.devsite-search-container');

다음을

const search = await page.$('aria/Open search[role="button"]');

검색창을 찾으세요

보다 일반적으로는 이러한 ARIA 선택기를 사용할 경우 Puppeteer 사용자에게 다음과 같은 이점이 있다고 믿습니다.

  • 테스트 스크립트의 선택기가 소스 코드 변경에 대해 더 복원력이 우수하도록 만듭니다.
  • 테스트 스크립트를 읽기 쉽게 만듭니다. 액세스 가능한 이름은 시맨틱 설명자입니다.
  • 요소에 접근성 속성을 할당하기 위한 권장사항을 장려합니다.

이 도움말의 나머지 부분에서는 Puppetaria 프로젝트를 구현한 방법을 자세히 설명합니다.

설계 프로세스

배경

위에서 설명한 것처럼, 액세스 가능한 이름과 역할로 요소를 쿼리할 수 있도록 하려고 합니다. 이는 접근성 트리의 속성으로, 일반적인 DOM 트리에 이중으로 구성되며 스크린 리더와 같은 기기에서 웹페이지를 표시하는 데 사용됩니다.

액세스 가능한 이름 계산 사양을 살펴본 결과 요소의 이름을 계산하는 작업이 그리 간단하지 않다는 것이 분명했으므로, 처음부터 Chromium의 기존 인프라를 재사용하기로 결정했습니다.

Google의 접근 방식

Chromium의 접근성 트리를 사용하는 것으로만 제한하더라도 Puppeteer에서 ARIA 쿼리를 구현할 수 있는 방법은 꽤 많습니다. 그 이유를 알아보기 위해 먼저 Puppeteer가 브라우저를 제어하는 방식을 살펴보겠습니다.

브라우저는 Chrome DevTools 프로토콜 (CDP)이라는 프로토콜을 통해 디버깅 인터페이스를 노출합니다. 이렇게 하면 언어 제약이 없는 인터페이스를 통해 '페이지 새로고침' 또는 '페이지에서 이 JavaScript를 실행한 후 결과 전달'과 같은 기능이 노출됩니다.

DevTools 프런트엔드와 Puppeteer는 모두 CDP를 사용하여 브라우저와 통신합니다. CDP 명령어를 구현하기 위해 브라우저, 렌더러 등 Chrome의 모든 구성요소 내에 DevTools 인프라가 있습니다. CDP는 명령어를 올바른 위치로 라우팅합니다.

표현식 쿼리, 클릭, 평가와 같은 Puppeteer 작업은 페이지 컨텍스트에서 직접 JavaScript를 평가하고 결과를 전달하는 Runtime.evaluate와 같은 CDP 명령어를 활용하여 실행됩니다. 색각 이상 에뮬레이션, 스크린샷 촬영, 트레이스 캡처와 같은 기타 Puppeteer 작업은 CDP를 사용하여 Blink 렌더링 프로세스와 직접 통신합니다.

CDP : 고객 데이터 플랫폼

따라서 쿼리 기능을 구현하기 위한 두 가지 경로가 이미 남아 있습니다. 다음을 실행할 수 있습니다.

  • 자바스크립트로 쿼리 로직을 작성하고 Runtime.evaluate을 사용하여 페이지에 삽입되도록 합니다.
  • Blink 프로세스에서 직접 접근성 트리에 액세스하고 쿼리할 수 있는 CDP 엔드포인트를 사용합니다.

3가지 프로토타입을 구현했습니다.

  • JS DOM 탐색 - 페이지에 자바스크립트를 삽입하는 방식 기준
  • Puppeteer AXTree 순회: 접근성 트리에 대한 기존 CDP 액세스 사용을 기반으로 합니다.
  • CDP DOM 탐색 - 접근성 트리 쿼리용으로 특별히 제작된 새로운 CDP 엔드포인트 사용

JS DOM 탐색

이 프로토타입은 DOM을 전체 순회하고 ComputedAccessibilityInfo 실행 플래그로 관리되는 element.computedNameelement.computedRole를 사용하여 순회 중에 각 요소의 이름과 역할을 가져옵니다.

Puppeteer AXTree 순회

여기서는 대신 CDP를 통해 전체 접근성 트리를 가져와 Puppeteer에서 순회합니다. 그러면 그 결과 접근성 노드가 DOM 노드에 매핑됩니다.

CDP DOM 탐색

이 프로토타입에서는 접근성 트리를 쿼리하기 위한 새로운 CDP 엔드포인트를 구현했습니다. 이렇게 하면 자바스크립트를 통한 페이지 컨텍스트 대신 C++ 구현을 통해 백엔드에서 쿼리가 발생할 수 있습니다.

단위 테스트 벤치마크

다음 그림은 3개의 프로토타입에서 4개 요소를 1,000회 쿼리하는 총 런타임을 비교합니다. 벤치마크는 페이지 크기 및 접근성 요소의 캐싱 사용 설정 여부에 따라 달라지는 3가지 다른 구성에서 실행되었습니다.

업계 기준치: 4개 요소를 1,000회 쿼리하는 총 런타임

CDP 지원 쿼리 메커니즘과 Puppeteer에서만 구현된 다른 두 쿼리 메커니즘 간에 상당한 성능 격차가 있다는 것이 분명하며, 페이지 크기에 따라 상대적인 차이가 크게 증가하는 것으로 보입니다. JS DOM 탐색 프로토타입이 접근성 캐싱을 사용하는 것에 매우 잘 반응한다는 점이 흥미롭습니다. 캐싱을 사용 중지하면 접근성 트리가 요청 시 계산되며, 도메인이 사용 중지된 경우 각 상호작용 후 트리가 삭제됩니다. 도메인을 사용 설정하면 Chromium이 계산된 트리를 대신 캐시합니다.

JS DOM 탐색을 위해 순회 중에 모든 요소에 액세스 가능한 이름과 역할을 요구하므로 캐싱이 비활성화된 경우 Chromium은 방문하는 모든 요소에 대해 접근성 트리를 계산하여 삭제합니다. 반면 CDP 기반 접근 방식의 경우 트리는 각 CDP 호출 사이에, 즉 모든 쿼리에 대해서만 삭제됩니다. 이러한 접근 방식은 캐싱을 사용 설정하여 이점을 얻을 수도 있습니다. 접근성 트리가 CDP 호출 전반에 걸쳐 유지되기 때문입니다. 하지만 성능 향상은 상대적으로 그보다 작습니다.

여기서는 캐싱을 사용 설정하는 것이 바람직해 보이지만 추가 메모리 사용 비용이 발생합니다. 추적 파일을 기록하는 Puppeteer 스크립트의 경우 문제가 될 수 있습니다. 따라서 Google에서는 기본적으로 접근성 트리 캐싱을 사용하지 않기로 결정했습니다. 사용자는 CDP 접근성 도메인을 사용 설정하여 캐싱을 직접 사용 설정할 수 있습니다.

DevTools 테스트 모음 벤치마크

이전 벤치마크에서는 CDP 레이어에서 쿼리 메커니즘을 구현하면 임상 단위 테스트 시나리오에서 성능을 향상할 수 있음을 보여주었습니다.

전체 테스트 모음을 실행하는 보다 현실적인 시나리오에서 차이가 두드러질 정도로 두드러지게 나타나는지 확인하기 위해 자바스크립트 및 CDP 기반 프로토타입을 활용할 수 있도록 DevTools 엔드 투 엔드 테스트 모음을 패치하고 런타임을 비교했습니다. 이 벤치마크에서는 총 43개의 선택기를 [aria-label=…]에서 맞춤 쿼리 핸들러 aria/…로 변경한 후 각 프로토타입을 사용하여 구현했습니다.

일부 선택기는 테스트 스크립트에서 여러 번 사용되므로 aria 쿼리 핸들러의 실제 실행 횟수는 도구 모음 실행당 113회였습니다. 쿼리 선택의 총 개수는 2, 253개이므로 선택한 쿼리 중 일부만 프로토타입을 통해 발생했습니다.

벤치마크: e2e 테스트 모음

위 그림에서 볼 수 있듯이 총 런타임에는 분명한 차이가 있습니다. 데이터에 노이즈가 너무 많아 구체적인 결론을 내릴 수 없지만, 이 시나리오에서도 두 프로토타입 간의 성능 격차가 분명합니다.

새 CDP 엔드포인트

위의 벤치마크를 고려할 때 그리고 출시 플래그 기반 접근 방식은 일반적으로 바람직하지 않으므로 접근성 트리 쿼리를 위한 새로운 CDP 명령어를 구현하기로 결정했습니다. 이제 이 새로운 엔드포인트의 인터페이스를 파악해야 했습니다.

Puppeteer 사용 사례의 경우 엔드포인트에서 소위 RemoteObjectIds를 인수로 사용해야 합니다. 이후에 해당 DOM 요소를 찾을 수 있으려면 엔드포인트에서 DOM 요소의 backendNodeIds가 포함된 객체 목록을 반환해야 합니다.

아래 차트에서 볼 수 있듯이, Google은 이 인터페이스를 만족시키기 위해 다양한 방법을 시도했습니다. 이를 통해 반환된 객체의 크기, 즉 전체 접근성 노드를 반환했는지 아니면 backendNodeIds만 반환했는지에 분명한 차이가 없음을 확인했습니다. 반면에 기존 NextInPreOrderIncludingIgnored를 사용하는 것은 여기에서 순회 로직을 구현하는 데 적합하지 않은 것으로 확인되었습니다. 이는 눈에 띄는 속도 저하를 초래하기 때문입니다.

업계 기준치: CDP 기반 AXTree 순회 프로토타입 비교

결론

이제 CDP 엔드포인트를 사용하여 Puppeteer 측에서 쿼리 핸들러를 구현했습니다. 이때 가장 어려웠던 점은 페이지 컨텍스트에서 평가된 JavaScript를 통해 쿼리하는 대신 쿼리가 CDP를 통해 직접 확인될 수 있도록 쿼리 처리 코드를 재구성하는 것이었습니다.

다음 단계

aria 핸들러는 Puppeteer v5.4.0과 함께 기본 제공 쿼리 핸들러로 제공됩니다. Google은 사용자들이 테스트 스크립트에 이 API를 어떻게 적용할지 매우 기대됩니다. 더욱 유용하게 사용할 수 있는 방법에 대한 여러분의 의견을 기다리겠습니다.

미리보기 채널 다운로드

Chrome Canary, 개발자 또는 베타를 기본 개발 브라우저로 사용하는 것이 좋습니다. 이러한 Preview 채널을 통해 최신 DevTools 기능에 액세스하고 최첨단 웹 플랫폼 API를 테스트하며 사용자보다 먼저 사이트에서 문제를 찾을 수 있습니다.

Chrome DevTools 팀에 문의하기

다음 옵션을 사용하여 게시물의 새로운 기능과 변경사항 또는 DevTools와 관련된 다른 모든 것에 대해 논의합니다.

  • crbug.com을 통해 제안이나 의견을 제출해 주세요.
  • DevTools에서 옵션 더보기   더보기   > 도움말 > DevTools 문제 보고를 사용하여 DevTools 문제를 신고합니다.
  • @ChromeDevTools로 트윗을 보냅니다.
  • DevTools의 새로운 기능 YouTube 동영상 또는 DevTools 팁 YouTube 동영상에 의견을 남겨주세요.