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 ona 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 sprowadza się do automatyzacji interakcji ze stroną internetową.

W Puppeteer uzyskuje się to, wysyłając zapytanie o elementy DOM za pomocą selektorów opartych na ciągach znaków i wykonując działania takie jak klikanie lub wpisywanie tekstu na 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 działania Puppeteer. Do tej pory selektory w Puppeteer ograniczały się do selektorów CSS i XPath, które, choć są bardzo skuteczne pod względem ekspresji, mogą mieć wady utrwalania interakcji przeglądarki w skryptach.

Selektory składowe a semantyczne

Selektory CSS mają charakter składniowy: są ściśle powiązane z wewnętrzną reprezentacją tekstu drzewa DOM w takim sensie, że odwołują się do identyfikatorów i nazw klas z DOM. Stanowią one integralne narzędzie dla programistów stron internetowych do modyfikowania lub dodawania stylów do elementu na stronie, ale w tym kontekście deweloper 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żywa się selektorów 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 wrażliwe na zmiany w kodzie źródłowym. Załóżmy na przykład, że do automatycznego testowania aplikacji internetowej używasz skryptów Puppeteer, a węzeł <button>Submit</button> jest trzecim elementem potomnym elementu body. Fragment z danego przypadku testowego może wyglądać tak:

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

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

To nie jest nowość dla autorów testów: użytkownicy Puppeteer już teraz próbują wybierać selektory, które są odporne na takie zmiany. Puppetaria udostępnia użytkownikom nowe narzędzie.

Puppeteer używa teraz alternatywnego modułu obsługi zapytań, który wysyła zapytania do drzewa ułatwień dostępu, zamiast korzystać z selektorów arkusza CSS. Zasada jest taka, że jeśli konkretny element, który chcemy wybrać, się nie zmienił, to odpowiedni węzeł dostępności też nie powinien się zmienić.

Nazywamy takie selektory „ARIA” i obsługujemy zapytania o obliczony widoczny element oraz rolę w drzewie ułatwień dostępu. W porównaniu z selektorami arkusza CSS te właściwości mają charakter semantyczny. Nie są one powiązane z właściwościami syntaktycznymi DOM, ale są opisami sposobu, w jaki strona jest obserwowana przez technologie wspomagające, takie jak czytniki ekranu.

W powyższym przykładzie ze skryptem testowym możemy zamiast tego użyć selektora aria/Submit[role="button"], aby wybrać żądany przycisk. Submit oznacza dostępną nazwę 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. Zmiana nazwy przycisku powoduje zmianę zawartości strony, a nie jej wizualnej prezentacji ani sposobu jej ustrukturyzowania w DOM. Testy powinny nas ostrzec przed takimi zmianami, aby mieć pewność, że są one zamierzone.

Wracając do większego przykładu z paskiem wyszukiwania, możemy użyć nowego modułu 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.

Ogólnie uważamy, że korzystanie z takich selektorów ARIA może przynieść użytkownikom Puppeteer następujące korzyści:

  • Ułatwienie selekcji w skryptach testowych dzięki zwiększeniu odporności na zmiany w kodzie źródłowym.
  • Ułatwić czytelność skryptów testowych (nazwy na potrzeby ułatwień dostępu to deskryptory semantyczne).
  • Zachęcaj do stosowania sprawdzonych metod przypisywania elementom właściwości ułatwień dostępu.

Z dalszej części tego artykułu dowiesz się, jak wdrożone zostały rozwiązania z projektu Puppetaria.

Proces projektowania

Tło

Jak już wspomnieliśmy, chcemy umożliwić wysyłanie zapytań o elementy według ich nazwy i odpowiedniej 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.

Specyfikacja obliczania nazwy na potrzeby ułatwień dostępu pokazuje, że obliczenie nazwy elementu nie jest proste, więc od początku zdecydowaliśmy, że chcemy wykorzystać w tym celu istniejącą infrastrukturę Chromium.

Jak podeszliśmy do wdrożenia

Nawet jeśli ograniczymy się do korzystania z drzewa dostępności w Chromium, istnieje kilka sposobów implementacji zapytań ARIA w Puppeteer. Aby przekonać się dlaczego, najpierw zobaczmy, jak Puppeteer steruje przeglądarką.

Przeglądarka udostępnia interfejs debugowania za pomocą protokołu Chrome DevTools Protocol (CDP). Dzięki temu można udostępniać funkcje takie jak „odśwież stronę” lub „wykonaj ten fragment kodu JavaScript na stronie i zwracaj wynik” za pomocą interfejsu niezależnego od języka.

Zarówno interfejs DevTools, jak i Puppeteer używają CDP do komunikacji z przeglądarką. Aby zaimplementować polecenia CDP, we wszystkich komponentach Chrome (w przeglądarce, w renderowaniu itp.) jest infrastruktura Narzędzi deweloperskich. CDP dba o kierowanie poleceń we właściwe miejsce.

Działania twórcy marionetek, takie jak zapytania, klikanie i ocenianie wyrażeń, są wykonywane przy użyciu poleceń CDP (np. Runtime.evaluate), które oceniają JavaScript bezpośrednio w kontekście strony i przekazują wynik. Inne działania Puppeteer, takie jak emulowanie niedoboru widzenia kolorów, robienie zrzutów ekranu czy rejestrowanie śladów, korzystają z CDP do bezpośredniej komunikacji z procesem renderowania Blink.

CDP

Pozostają nam 2 ścieżki implementacji funkcji zapytań:

  • Napisz naszą logikę zapytań w języku JavaScript i umieść ją na stronie za pomocą atrybutu Runtime.evaluate lub
  • Użyj punktu końcowego CDP, który może uzyskiwać dostęp do drzewa ułatwień dostępu i wysyłać do niego zapytania bezpośrednio w procesie Blink.

Wprowadziliśmy 3 prototypy:

  • Przeglądanie DOM w JavaScriptzie – polega na wstrzykiwaniu JavaScriptu na stronie.
  • Przeglądanie za pomocą Puppeteer AXTree – na podstawie użycia istniejącego dostępu CDP do drzewa ułatwień dostępu.
  • Przeszukiwanie DOM w CDP – korzystanie z nowego punktu końcowego CDP, który został stworzony specjalnie do wykonywania zapytań do drzewa dostępności.

Przeszukiwanie DOM w JS

Ten prototyp wykonuje pełne przejście przez DOM i korzysta z elementów element.computedNameelement.computedRole, które są ograniczone flagą ComputedAccessibilityInfo, aby pobierać nazwę i rolę każdego elementu podczas przechodzenia.

Przeszukiwanie AXTree za pomocą Puppeteer

W tym przypadku zamiast tego pobieramy pełne drzewo ułatwień dostępu za pomocą CDP i przechodzimy przez nie w Puppeteer. Uzyskane w ten sposób węzły ułatwień dostępu są następnie mapowane na węzły modelu DOM.

Przeszukiwanie CDP w DOM

W tym prototypie wdrożyliśmy nowy punkt końcowy CDP specjalnie do zapytań dotyczących drzewa dostępności. Dzięki temu zapytania mogą być wysyłane na zapleczu za pomocą implementacji w C++, a nie w kontekście strony za pomocą JavaScriptu.

Test jednostkowy – test porównawczy

Na poniższym rysunku porównano łączny czas wykonywania zapytań do 4 elementów 1000 razy w przypadku 3 prototypów. Test porównawczy został przeprowadzony w 3 różnych konfiguracjach z różnym rozmiarem strony i z włączonym lub wyłączonym buforowaniem elementów ułatwień dostępu.

Test porównawczy: łączny czas wykonywania zapytań dotyczących 4 elementów 1000 razy

Wyraźnie widać, że występuje duża rozbieżność w wydajności mechanizmu wysyłania zapytań wspieranego przez CDP a 2 innymi mechanizmami zaimplementowanymi wyłącznie w Puppeteer. Różnica ta wydaje się jednak radykalnie rosnąć wraz z rozmiarem strony. Ciekawe jest to, że prototyp JS DOM traversal reaguje tak dobrze na włączanie pamięci podręcznej 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 zamiast tego przechowuje w pamięci podręcznej wyliczoną strukturę.

W przypadku przeglądania DOM w JS prosimy o dostępną nazwę i rolę każdego elementu podczas przeglądania, więc jeśli buforowanie jest wyłączone, Chromium oblicza i odrzuca drzewo ułatwień dostępu dla każdego odwiedzanego elementu. W przypadku metod opartych na CDP drzewo jest odrzucane tylko między każdym wywołaniem CDP, czyli przy każdym zapytaniu. Te rozwiązania również uzyskują korzyści z włączenia buforowania, ponieważ drzewo ułatwień dostępu jest następnie zachowywane w wywołaniach CDP, ale wzrost wydajności jest więc stosunkowo mniejszy.

Chociaż w tym przypadku włączenie buforowania wydaje się być dobrym rozwiązaniem, wiąże się to z dodatkowym wykorzystaniem pamięci. W przypadku skryptów Puppeteer, które na przykład rejestrują pliki śledzenia, może to być problematyczne. W związku z tym postanowiliśmy domyślnie nie włączać buforowania drzewa ułatwień dostępu. Użytkownicy mogą włączyć buforowanie samodzielnie, aktywując domenę Dostępność w CDP.

Zestaw testów DevTools

Poprzednie testy porównawcze wykazały, że implementacja naszego mechanizmu zapytań na poziomie CDP zwiększa wydajność w sytuacji testu jednostkowego w klinice.

Aby sprawdzić, czy różnica jest wystarczająco wyraźna, aby była zauważalna w bardziej realistycznym scenariuszu uruchamiania pełnego zestawu testów, wprowadziliśmy poprawki w całościowym zestawie testów DevTools, aby wykorzystać prototypy oparte na JavaScript i CDP, i porównać czasy wykonywania. W testach porównawczych zmieniliśmy w sumie 43 selektory z [aria-label=…] na niestandardowy moduł obsługi zapytań aria/…, który potem wdrożyliśmy przy użyciu każdego z prototypów.

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

Test porównawczy: pakiet testów e2e

Jak widać na rysunku powyżej, jest wyraźna różnica w łącznym czasie działania. Dane są zbyt niejednoznaczne, aby można było wyciągnąć konkretne wnioski, ale w tym scenariuszu też widać różnicę w skuteczności między dwoma prototypami.

Nowy punkt końcowy CDP

Z uwagi na powyższe wyniki i fakt, że podejście oparte na flagach uruchomienia było ogólnie niepożądane, zdecydowaliśmy się wdrożyć nowe polecenie CDP do zapytania drzewa ułatwień dostępu. Musieliśmy teraz opracować interfejs nowego punktu końcowego.

W przypadku Puppeteer potrzebujemy punktu końcowego, który przyjmuje jako argument tzw. RemoteObjectIds, a aby umożliwić nam późniejsze znajdowanie odpowiednich elementów DOM, powinien zwracać listę obiektów zawierającą backendNodeIds dla elementów DOM.

Jak widać na wykresie poniżej, wypróbowaliśmy kilka rozwiązań, które spełniają wymagania dotyczące tego interfejsu. Na tej podstawie stwierdziliśmy, że rozmiar zwracanych obiektów, czyli to, czy zwróciliśmy pełne węzły dostępności, czy tylko węzły backendNodeIds, nie miało żadnego zauważalnego wpływu. Z drugiej strony okazało się, że wykorzystanie istniejącego NextInPreOrderIncludingIgnored nie było dobrym rozwiązaniem do wdrożenia logiki przemierzania, ponieważ skutkowało to zauważalnym spowolnieniem działania.

Benchmark: porównanie prototypów przeszukiwania AXTree na podstawie CDP

Podsumowanie

Teraz, mając punkt końcowy CDP, wdrożyliśmy moduł obsługi zapytań po stronie Puppeteer. Głównym zadaniem było przekształcenie kodu obsługującego zapytania, aby umożliwić ich rozwiązywanie bezpośrednio przez CDP zamiast przez JavaScript oceniany w kontekście strony.

Co dalej?

Nowy moduł obsługi aria jest dostarczany z Puppeteer w wersji 5.4.0 jako wbudowany moduł obsługi zapytań. Czekamy na to, jak użytkownicy wykorzystają tę funkcję w swoich skryptach testowych, i z niecierpliwością czekamy na Wasze pomysły na to, jak możemy ją jeszcze bardziej udoskonalić.

Pobierz kanały podglądu

Rozważ użycie przeglądarki Chrome Canary, Dev lub Beta jako domyślnej przeglądarki deweloperskiej. Te kanały wersji wstępnej zapewniają dostęp do najnowszych funkcji DevTools, umożliwiają testowanie najnowocześniejszych interfejsów API platformy internetowej i pomagają znaleźć problemy w witrynie, zanim zrobią to użytkownicy.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

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