Puppetaria: skrypty Puppeteer stworzone z myślą o ułatwieniach dostępu

Johan Bay
Johan Bay

Puppeteer i jego podejście do selektorów

Puppeteer to biblioteka automatyzacji przeglądarki dla Node: umożliwia sterowanie przeglądarką za pomocą prostego i nowoczesnego interfejsu JavaScript API.

Najważniejszym zadaniem przeglądarki jest oczywiście przeglądanie stron internetowych. Automatyzacja tego zadania polega zasadniczo na automatyzacji interakcji ze stroną internetową.

W Puppeteerze można to osiągnąć, wysyłając zapytania o elementy DOM za pomocą selektorów opartych na ciągach znaków i wykonując działania takie jak klikanie lub wpisywanie tekstu w elementach. Na przykład skrypt, który otwiera stronę developer.google.com, znajduje pole wyszukiwania i wyszukuje hasło puppetaria, może wyglądać tak:

(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');
 })();

Sposób identyfikowania elementów za pomocą selektorów zapytań jest więc kluczowym elementem korzystania z Puppeteera. Do tej pory selektory w Puppeteer były ograniczone do selektorów CSS i XPath, które, choć bardzo zaawansowane, mogą mieć wady w przypadku utrwalania interakcji z przeglądarką w skryptach.

Selektory składniowe a semantyczne

Selektory CSS mają charakter syntaktyczny. Są ściśle powiązane z wewnętrznym działaniem tekstowej reprezentacji drzewa DOM, ponieważ odwołują się do identyfikatorów i nazw klas z drzewa DOM. Są one więc integralnym narzędziem dla programistów stron internetowych, które umożliwia modyfikowanie lub dodawanie stylów do elementu na stronie, ale w tym kontekście programista ma pełną kontrolę nad stroną i jej drzewem DOM.

Z drugiej strony skrypt Puppeteer jest zewnętrznym obserwatorem strony, więc gdy w tym kontekście używane są selektory CSS, wprowadza on ukryte założenia dotyczące sposobu implementacji strony, nad którymi skrypt Puppeteer nie ma kontroli.

W efekcie takie skrypty mogą być niestabilne i podatne na zmiany kodu źródłowego. Załóżmy na przykład, że ktoś używa skryptów Puppeteer do automatycznego testowania aplikacji internetowej zawierającej węzeł <button>Submit</button> jako trzeci element podrzędny elementu body. Fragment kodu z elementu testowania może wyglądać tak:

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

W tym przypadku używamy selektora 'body:nth-child(3)', aby znaleźć przycisk przesyłania, ale jest on ściśle powiązany z tą wersją strony internetowej. Jeśli później nad przyciskiem zostanie dodany element, ten selektor przestanie działać.

Nie jest to nic nowego dla osób piszących testy: użytkownicy Puppeteer już próbują wybierać selektory, które są odporne na takie zmiany. Puppetaria to nowe narzędzie, które im w tym pomoże.

Puppeteer jest teraz dostarczany z alternatywnym modułem obsługi zapytań, który opiera się na wysyłaniu zapytań do drzewa ułatwień dostępu, a nie na selektorach CSS. Podstawowa zasada jest taka, że jeśli konkretny element, który chcemy wybrać, nie uległ zmianie, odpowiadający mu węzeł ułatwień dostępu również nie powinien się zmienić.

Takie selektory nazywamy „selektorami ARIA” i obsługujemy zapytania o obliczoną nazwę na potrzeby ułatwień dostępu i rolę dostępną w drzewie ułatwień dostępu. W porównaniu z selektorami CSS te właściwości mają charakter semantyczny. Nie są one powiązane z właściwościami składniowymi modelu DOM, ale są deskryptorami sposobu, w jaki strona jest postrzegana przez technologie wspomagające osoby z niepełnosprawnością, takie jak czytniki ekranu.

W powyższym przykładzie scenariusza testowania możemy użyć selektora aria/Submit[role="button"], aby wybrać odpowiedni przycisk, gdzie Submit odnosi się do nazwy na potrzeby ułatwień dostępu elementu:

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

Jeśli później zdecydujemy się zmienić tekst przycisku z Submit na Done, test znów się nie powiedzie, ale w tym przypadku jest to pożądane. Zmieniając nazwę przycisku, zmieniamy zawartość strony, a nie jej prezentację wizualną czy strukturę w DOM. Nasze testy powinny ostrzegać nas o takich zmianach, abyśmy mieli pewność, że są one zamierzone.

Wracając do większego przykładu z paskiem wyszukiwania, możemy wykorzystać nowy moduł obsługi aria i zastąpić

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

z

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

aby znaleźć pasek wyszukiwania.

Uważamy, że używanie takich selektorów ARIA może przynieść użytkownikom Puppeteer następujące korzyści:

  • Zwiększ odporność selektorów w skryptach testowych na zmiany kodu źródłowego.
  • skrypty testowe są bardziej czytelne (nazwy na potrzeby ułatwień dostępu są deskryptorami semantycznymi).
  • zachęcanie do stosowania sprawdzonych metod przypisywania właściwości ułatwień dostępu do elementów.

W dalszej części tego artykułu znajdziesz szczegółowe informacje o tym, jak wdrożyliśmy projekt Puppetaria.

Proces projektowania

Tło

Jak wspomnieliśmy powyżej, chcemy umożliwić wysyłanie zapytań o elementy według ich nazwy na potrzeby ułatwień dostępu i roli. Są to właściwości drzewa ułatwień dostępu, które jest odpowiednikiem zwykłego drzewa DOM i jest używane przez urządzenia takie jak czytniki ekranu do wyświetlania stron internetowych.

Ze specyfikacji obliczania nazwy na potrzeby ułatwień dostępu jasno wynika, że obliczanie nazwy elementu nie jest prostym zadaniem, dlatego od początku zdecydowaliśmy, że chcemy ponownie wykorzystać istniejącą infrastrukturę Chromium.

Jak podeszliśmy do wdrożenia

Nawet jeśli ograniczymy się do korzystania z drzewa ułatwień dostępu Chromium, istnieje wiele sposobów na wdrożenie zapytań ARIA w Puppeteer. Aby dowiedzieć się, dlaczego, najpierw zobaczmy, jak Puppeteer steruje przeglądarką.

Przeglądarka udostępnia interfejs debugowania za pomocą protokołu o nazwie protokół narzędzi deweloperskich w Chrome (CDP). Udostępnia ona funkcje takie jak „odśwież stronę” lub „wykonaj ten fragment kodu JavaScript na stronie i zwróć wynik” za pomocą interfejsu niezależnego od języka.

Zarówno interfejs Narzędzi deweloperskich, jak i Puppeteer używają CDP do komunikacji z przeglądarką. Aby implementować polecenia CDP, we wszystkich komponentach Chrome (w przeglądarce, rendererze itd.) znajduje się infrastruktura Narzędzi deweloperskich. CDP zajmuje się kierowaniem poleceń we właściwe miejsce.

Działania Puppeteer, takie jak wysyłanie zapytań, klikanie i ocenianie wyrażeń, są wykonywane za pomocą poleceń CDP, takich jak Runtime.evaluate, które oceniają kod JavaScript bezpośrednio w kontekście strony i zwracają wynik. Inne działania Puppeteer, takie jak emulowanie zaburzeń postrzegania barw, robienie zrzutów ekranu czy przechwytywanie śladów, używają CDP do bezpośredniej komunikacji z procesem renderowania Blink.

CDP

To daje nam 2 sposoby na wdrożenie funkcji wysyłania zapytań. Możemy:

  • napisać logikę zapytania w języku JavaScript i wstrzyknąć ją do strony za pomocą Runtime.evaluate;
  • Użyj punktu końcowego CDP, który może uzyskiwać dostęp do drzewa ułatwień dostępu i wykonywać na nim zapytania bezpośrednio w procesie Blink.

Wdrożyliśmy 3 prototypy:

  • Przechodzenie po DOM za pomocą JS – oparte na wstrzykiwaniu kodu JavaScript na stronę.
  • Przechodzenie po drzewie AXTree w Puppeteer – oparte na użyciu istniejącego dostępu CDP do drzewa ułatwień dostępu.
  • Przechodzenie po modelu DOM w CDP – używanie nowego punktu końcowego CDP utworzonego specjalnie na potrzeby wysyłania zapytań do drzewa ułatwień dostępu.

Przechodzenie po DOM w JS

Ten prototyp wykonuje pełne przechodzenie po DOM i używa element.computedName oraz element.computedRole, zablokowanych przez flagę uruchamiania ComputedAccessibilityInfo, aby pobrać nazwę i rolę każdego elementu podczas przechodzenia.

Przechodzenie po drzewie AXTree w Puppeteer

W tym przypadku pobieramy pełne drzewo ułatwień dostępu za pomocą CDP i przechodzimy przez nie w Puppeteer. Powstałe węzły ułatwień dostępu są następnie mapowane na węzły DOM.

Przechodzenie po DOM w CDP

W tym prototypie wdrożyliśmy nowy punkt końcowy CDP, który służy do wysyłania zapytań do drzewa ułatwień dostępu. Dzięki temu zapytania mogą być wysyłane w backendzie za pomocą implementacji C++, a nie w kontekście strony za pomocą JavaScriptu.

Test porównawczy testów jednostkowych

Poniższy wykres porównuje łączny czas wykonywania zapytania o 4 elementy 1000 razy w przypadku 3 prototypów. Test porównawczy został przeprowadzony w 3 różnych konfiguracjach, które różniły się rozmiarem strony i tym, czy włączone było buforowanie elementów ułatwień dostępu.

Test porównawczy: łączny czas wykonywania zapytania o 4 elementy 1000 razy

Widać wyraźnie, że istnieje znaczna różnica w wydajności między mechanizmem wysyłania zapytań opartym na CDP a dwoma innymi mechanizmami zaimplementowanymi wyłącznie w Puppeteerze. Różnica względna wydaje się gwałtownie rosnąć wraz z rozmiarem strony. To ciekawe, że prototyp przechodzenia po DOM w JS tak dobrze reaguje na włączenie buforowania ułatwień dostępu. Gdy buforowanie jest wyłączone, drzewo ułatwień dostępu jest obliczane na żądanie i odrzucane po każdej interakcji, jeśli domena jest wyłączona. Włączenie domeny powoduje, że Chromium zapisuje w pamięci podręcznej obliczone drzewo.

W przypadku przechodzenia po modelu DOM w JS prosimy o podanie nazwy na potrzeby ułatwień dostępu i roli każdego elementu, więc jeśli buforowanie jest wyłączone, Chromium oblicza i odrzuca drzewo ułatwień dostępu dla każdego odwiedzanego elementu. W przypadku podejść opartych na CDP drzewo jest odrzucane tylko między poszczególnymi wywołaniami CDP, czyli dla każdego zapytania. Te podejścia również korzystają z włączenia buforowania, ponieważ drzewo ułatwień dostępu jest wtedy zachowywane między wywołaniami CDP, ale wzrost wydajności jest stosunkowo mniejszy.

Włączenie buforowania wydaje się w tym przypadku pożądane, ale wiąże się z dodatkowym wykorzystaniem pamięci. W przypadku skryptów Puppeteer, które np. rejestrują pliki śledzenia, może to być problematyczne. Dlatego postanowiliśmy nie włączać domyślnie buforowania drzewa ułatwień dostępu. Użytkownicy mogą samodzielnie włączyć buforowanie, włączając domenę ułatwień dostępu CDP.

Test porównawczy zestawu testów Narzędzi deweloperskich

Poprzedni test porównawczy wykazał, że wdrożenie naszego mechanizmu wysyłania zapytań na poziomie CDP zwiększa wydajność w scenariuszu testu jednostkowego klinicznego.

Aby sprawdzić, czy różnica jest na tyle wyraźna, że można ją zauważyć w bardziej realistycznym scenariuszu uruchamiania pełnego zestawu testów, zmodyfikowaliśmy zestaw testów kompleksowych Narzędzi deweloperskich, aby korzystał z prototypów opartych na JavaScript i CDP, i porównaliśmy czasy działania. W tym teście porównawczym zmieniliśmy łącznie 43 selektory z [aria-label=…] na niestandardowy moduł obsługi zapytań aria/…, który następnie zaimplementowaliśmy za pomocą każdego z prototypów.

Niektóre selektory są używane wielokrotnie w skryptach testowych, więc rzeczywista liczba wykonań modułu obsługi zapytań aria wynosiła 113 na każde uruchomienie pakietu. Łączna liczba wyborów zapytań wyniosła 2253, więc tylko niewielka część wyborów zapytań została dokonana za pomocą prototypów.

Test porównawczy: zestaw testów E2E

Jak widać na ilustracji powyżej, istnieje zauważalna różnica w łącznym czasie działania. Dane są zbyt niejednoznaczne, aby można było wyciągnąć konkretne wnioski, ale widać, że różnica w wydajności między tymi 2 prototypami występuje również w tym scenariuszu.

nowy punkt końcowy CDP,

Biorąc pod uwagę powyższe testy porównawcze i fakt, że podejście oparte na flagach uruchamiania było ogólnie niepożądane, postanowiliśmy wdrożyć nowe polecenie CDP do wysyłania zapytań do drzewa ułatwień dostępu. Teraz musieliśmy opracować interfejs tego nowego punktu końcowego.

W naszym przypadku użycia w Puppeteer potrzebujemy punktu końcowego, który przyjmuje tzw. RemoteObjectIds jako argument i zwraca listę obiektów zawierających backendNodeIds dla elementów DOM, abyśmy mogli później znaleźć odpowiednie elementy DOM.

Jak widać na wykresie poniżej, wypróbowaliśmy kilka podejść, które spełniają wymagania tego interfejsu. Na tej podstawie stwierdziliśmy, że rozmiar zwracanych obiektów, czyli to, czy zwracamy pełne węzły ułatwień dostępu, czy tylko backendNodeIds, nie ma zauważalnego wpływu na wydajność. Z drugiej strony stwierdziliśmy, że użycie istniejącego NextInPreOrderIncludingIgnored było złym wyborem do implementacji logiki przechodzenia, ponieważ powodowało zauważalne spowolnienie.

Test porównawczy: porównanie prototypów przechodzenia po drzewie AXTree na podstawie CDP

Podsumowanie

Teraz, gdy punkt końcowy CDP jest już na miejscu, zaimplementowaliśmy moduł obsługi zapytań po stronie Puppeteer. Większość pracy polegała na restrukturyzacji kodu obsługi zapytań, aby umożliwić ich rozwiązywanie bezpośrednio przez CDP, a nie przez JavaScript oceniany w kontekście strony.

Co dalej?

Nowy moduł obsługi aria został dostarczony z Puppeteerem w wersji 5.4.0 jako wbudowany moduł obsługi zapytań. Czekamy na informacje o tym, jak użytkownicy wykorzystują tę funkcję w swoich skryptach testowych, i chętnie poznamy Twoje pomysły na to, jak możemy ją jeszcze ulepszyć.

Pobieranie kanałów podglądu

Rozważ użycie Chrome Canary, Dev lub Beta jako domyślnej przeglądarki do programowania. Te kanały wersji testowej dają dostęp do najnowszych funkcji Narzędzi deweloperskich, umożliwiają testowanie najnowocześniejszych interfejsów API platformy internetowej i pomagają znajdować problemy w witrynie, zanim zrobią to użytkownicy.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

Skorzystaj z poniższych opcji, aby omówić nowe funkcje, aktualizacje lub inne kwestie związane z Narzędziami deweloperskimi.