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

Johan Bay
Johan Bay

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

Puppeteer는 Node용 브라우저 자동화 라이브러리로, 간단한 최신 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 렌더링 프로세스와 직접 통신합니다.

콘텐츠 데이터 보호

이렇게 하면 쿼리 기능을 구현할 수 있는 두 가지 경로가 남게 됩니다.

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

우리는 다음과 같은 3가지 프로토타입을 구현했습니다.

  • JS DOM 탐색 - 페이지에 JavaScript 삽입 기반
  • Puppeteer AXTree 순회 - 접근성 트리에 대한 기존 CDP 액세스 사용 기반
  • CDP DOM 순회 - 접근성 트리 쿼리를 위해 특별히 제작된 새로운 CDP 엔드포인트 사용

JS DOM 순회

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

인형극 AXTree 순회

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

CDP DOM 순회

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

단위 테스트 벤치마크

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

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

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

JS DOM 탐색의 경우 순회 중에 모든 요소에 대해 액세스 가능한 이름과 역할을 요청하므로 캐싱이 사용 중지되면 Chromium은 사용자가 방문하는 모든 요소에 대해 접근성 트리를 계산하고 삭제합니다. 반면 CDP 기반 접근 방식의 경우 트리는 각 CDP 호출 간에, 즉 모든 쿼리에 대해서만 삭제됩니다. 이러한 접근 방식은 캐싱을 사용 설정하는 것도 도움이 됩니다. 접근성 트리가 CDP 호출 전체에서 지속되기 때문에 성능 향상은 비교적 작기 때문입니다.

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

DevTools 테스트 모음 벤치마크

이전 벤치마크는 CDP 레이어에서 쿼리 메커니즘을 구현하면 임상 단위 테스트 시나리오에서 성능을 향상하는 것으로 나타났습니다.

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

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

벤치마크: e2e 테스트 모음

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

새 CDP 엔드포인트

위의 벤치마크와 출시 플래그 기반 접근 방식이 일반적으로 바람직하지 않다는 점을 감안하여 Google은 접근성 트리 쿼리를 위한 새로운 CDP 명령어를 구현하는 작업을 진행하기로 결정했습니다. 이제 이 새로운 엔드포인트의 인터페이스를 파악해야 했습니다.

Puppeteer 사용 사례에서는 엔드포인트가 RemoteObjectIds를 인수로 사용해야 하며, 이후에 상응하는 DOM 요소를 찾을 수 있도록 하려면 DOM 요소의 backendNodeIds가 포함된 객체 목록을 반환해야 합니다.

아래 차트에서 볼 수 있듯이, 저희는 이 인터페이스를 만족시키기 위해 꽤 많은 접근 방식을 시도했습니다. 이를 통해 반환된 객체의 크기, 즉 전체 접근성 노드를 반환했는지 아니면 backendNodeIds만 반환했는지에 따라 눈에 띄는 차이가 없음을 확인했습니다. 반면에 기존 NextInPreOrderIncludingIgnored를 사용하는 것은 순회 로직을 구현하는 데 좋지 않은 선택이며, 현저한 속도 저하가 발생한 것으로 나타났습니다.

벤치마크: CDP 기반 AXTree 순회 프로토타입 비교

요약

이제 CDP 엔드포인트를 사용하여 Puppeteer 측에 쿼리 핸들러를 구현했습니다. 여기서의 까다로운 작업은 페이지 컨텍스트에서 평가되는 JavaScript를 통해 쿼리하는 대신 CDP를 통해 쿼리를 직접 확인할 수 있도록 쿼리 처리 코드를 재구성하는 것이었습니다.

다음 단계

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

미리보기 채널 다운로드

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

Chrome DevTools 팀에 문의하기

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

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