Puppeteer en de aanpak voor selectors
Puppeteer is een bibliotheek voor browserautomatisering voor Node. Hiermee kun je een browser beheren met een eenvoudige en moderne JavaScript-API.
De meest prominente browsertaak is natuurlijk browsen op webpagina's. Deze taak automatiseren komt neer op interacties met de webpagina automatiseren.
In Puppeteer wordt dit bereikt door DOM-elementen op te vragen met op tekenreeksen gebaseerde kiezers en acties uit te voeren, zoals klikken of tekst typen op de elementen. Een script dat bijvoorbeeld developer.google.com opent, het zoekvak vindt en zoekt naar puppetaria, kan er zo uitzien:
(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');
})();
Hoe elementen worden geïdentificeerd met queryselectors is daarom een bepalend onderdeel van de Puppeteer-functionaliteit. Tot nu toe waren kiezers in Puppeteer beperkt tot css- en XPath-kiezers. Hoewel deze zeer krachtig zijn, kunnen ze nadelen hebben voor het behouden van browserinteracties in scripts.
Syntactische versus semantische kiezers
CSS-kiezers zijn syntactisch van aard. Ze zijn nauw verbonden met de interne werking van de tekstuele weergave van de DOM-boomstructuur, omdat ze verwijzen naar ID's en klassenamen uit de DOM. Ze bieden dus een integraal hulpmiddel voor webontwikkelaars om stijlen aan te passen of toe te voegen aan een element op een pagina, maar in die context heeft de ontwikkelaar volledige controle over de pagina en de DOM-boomstructuur.
Een Puppeteer-script is daarentegen een externe waarnemer van een pagina. Als er in deze context css-kiezers worden gebruikt, worden er verborgen aannames geïntroduceerd over hoe de pagina is geïmplementeerd, waarover het Puppeteer-script geen controle heeft.
Het effect is dat dergelijke scripts kwetsbaar kunnen zijn en gevoelig voor wijzigingen in de broncode. Stel dat iemand Puppeteer-scripts gebruikt voor geautomatiseerde tests voor een web-app die de node <button>Submit</button> bevat als het derde onderliggende element van het element body. Een fragment uit een testcase kan er zo uitzien:
const button = await page.$('body:nth-child(3)'); // problematic selector
await button.click();
Hier gebruiken we de kiezer 'body:nth-child(3)' om de knop Verzenden te vinden, maar deze is nauw verbonden met precies deze versie van de webpagina. Als er later een element boven de knop wordt toegevoegd, werkt deze kiezer niet meer.
Dit is geen nieuws voor testschrijvers: Puppeteer-gebruikers proberen al kiezers te kiezen die bestand zijn tegen dergelijke wijzigingen. Met Puppetaria geven we gebruikers een nieuwe tool voor deze zoektocht.
Puppeteer wordt nu geleverd met een alternatieve queryhandler die is gebaseerd op het uitvoeren van query's op de toegankelijkheidsstructuur in plaats van te vertrouwen op CSS-kiezers. De onderliggende filosofie is dat als het concrete element dat we willen selecteren niet is gewijzigd, de bijbehorende toegankelijkheidsnode ook niet is gewijzigd.
We noemen deze kiezers ARIA-kiezers en ondersteunen query's voor de berekende toegankelijke naam en rol van de toegankelijkheidsstructuur. In vergelijking met de CSS-kiezers zijn deze eigenschappen semantisch van aard. Ze zijn niet gekoppeld aan syntactische eigenschappen van het DOM, maar zijn beschrijvingen van hoe de pagina wordt waargenomen via ondersteunende technologieën zoals schermlezers.
In het voorbeeld van het testscript hierboven kunnen we in plaats daarvan de kiezer aria/Submit[role="button"] gebruiken om de gewenste knop te selecteren, waarbij Submit verwijst naar de toegankelijke naam van het element:
const button = await page.$('aria/Submit[role="button"]');
await button.click();
Als we later besluiten de tekstcontent van onze knop te wijzigen van Submit in Done, mislukt de test opnieuw. In dit geval is dat wenselijk. Door de naam van de knop te wijzigen, wijzigen we de content van de pagina, in tegenstelling tot de visuele presentatie of de structuur in de DOM. Onze tests moeten ons waarschuwen voor dergelijke wijzigingen om te zorgen dat deze wijzigingen opzettelijk zijn.
Als we teruggaan naar het grotere voorbeeld met de zoekbalk, kunnen we de nieuwe aria-handler gebruiken en
const search = await page.$('devsite-search > form > div.devsite-search-container');
met
const search = await page.$('aria/Open search[role="button"]');
om de zoekbalk te vinden.
Meer in het algemeen denken we dat het gebruik van dergelijke ARIA-selectors de volgende voordelen kan bieden aan Puppeteer-gebruikers:
- Maak selectors in testscripts beter bestand tegen wijzigingen in de broncode.
- Testscripts beter leesbaar maken (toegankelijke namen zijn semantische beschrijvingen).
- Moedig goede praktijken aan voor het toewijzen van toegankelijkheidseigenschappen aan elementen.
In de rest van dit artikel gaan we dieper in op hoe we het Puppetaria-project hebben geïmplementeerd.
Het ontwerpproces
Achtergrond
Zoals hierboven gemotiveerd, willen we het mogelijk maken om elementen op te vragen op basis van hun toegankelijke naam en rol. Dit zijn eigenschappen van de toegankelijkheidsstructuur, een dubbele van de gebruikelijke DOM-boomstructuur, die wordt gebruikt door apparaten zoals schermlezers om webpagina's te tonen.
Als we kijken naar de specificatie voor het berekenen van de toegankelijke naam, is het duidelijk dat het berekenen van de naam voor een element geen eenvoudige taak is. Daarom hebben we vanaf het begin besloten dat we de bestaande infrastructuur van Chromium hiervoor wilden hergebruiken.
Hoe we de implementatie hebben aangepakt
Zelfs als we ons beperken tot het gebruik van de toegankelijkheidsstructuur van Chromium, zijn er nogal wat manieren waarop we ARIA-query's in Puppeteer kunnen implementeren. Laten we eerst kijken hoe Puppeteer de browser beheert om te zien waarom.
De browser maakt een foutopsporingsinterface beschikbaar via een protocol dat het Chrome DevTools-protocol (CDP) wordt genoemd. Hierdoor wordt functionaliteit zoals 'de pagina opnieuw laden' of 'dit stukje JavaScript op de pagina uitvoeren en het resultaat teruggeven' beschikbaar via een taalagnostische interface.
Zowel de DevTools-frontend als Puppeteer gebruiken CDP om met de browser te communiceren. Voor de implementatie van CDP-opdrachten is er een DevTools-infrastructuur in alle componenten van Chrome: in de browser, in de renderer, enzovoort. CDP zorgt dat de opdrachten naar de juiste plaats worden geleid.
Puppeteer-acties zoals query's uitvoeren, klikken en expressies evalueren, worden uitgevoerd met CDP-opdrachten zoals Runtime.evaluate, die JavaScript rechtstreeks in de paginacontext evalueert en het resultaat teruggeeft. Andere Puppeteer-acties, zoals het nabootsen van kleurenblindheid, screenshots maken of traces vastleggen, gebruiken CDP om rechtstreeks te communiceren met het Blink-renderingproces.

We hebben nu 2 manieren om onze queryfunctionaliteit te implementeren. We kunnen:
- Schrijf onze querylogica in JavaScript en laat deze in de pagina injecteren met
Runtime.evaluate, of - Gebruik een CDP-eindpunt dat de toegankelijkheidsstructuur rechtstreeks in het Blink-proces kan openen en doorzoeken.
We hebben 3 prototypes geïmplementeerd:
- JS DOM-traversal: gebaseerd op het injecteren van JavaScript in de pagina
- Puppeteer AXTree-traversal: gebaseerd op het gebruik van de bestaande CDP-toegang tot de toegankelijkheidsstructuur
- CDP DOM-traversal: een nieuw CDP-eindpunt gebruiken dat speciaal is ontworpen om de toegankelijkheidsstructuur op te vragen
JS DOM-traversal
Dit prototype doorloopt de hele DOM en gebruikt element.computedName en element.computedRole, afgeschermd door de startvlag ComputedAccessibilityInfo, om de naam en rol voor elk element op te halen tijdens de doorloop.
Puppeteer AXTree-traversal
In plaats daarvan halen we de hele toegankelijkheidsstructuur op via CDP en doorlopen we deze in Puppeteer. De resulterende toegankelijkheidsnodes worden dan toegewezen aan DOM-nodes.
CDP DOM-traversal
Voor dit prototype hebben we een nieuw CDP-eindpunt geïmplementeerd, specifiek voor het uitvoeren van query's op de toegankelijkheidsstructuur. Op deze manier kan de query op de backend worden uitgevoerd via een C++-implementatie in plaats van in de paginacontext via JavaScript.
Benchmark voor unittests
In de volgende afbeelding wordt de totale runtime van 1000 keer een query uitvoeren op 4 elementen vergeleken voor de 3 prototypes. De benchmark is uitgevoerd in 3 verschillende configuraties, waarbij de paginagrootte varieerde en caching van toegankelijkheidselementen wel of niet was aangezet.
Het is duidelijk dat er een aanzienlijk prestatieverschil is tussen het CDP-ondersteunde querymechanisme en de 2 andere mechanismen die alleen in Puppeteer zijn geïmplementeerd. Het relatieve verschil lijkt drastisch toe te nemen met de paginagrootte. Het is interessant om te zien dat het JS DOM-traversal-prototype zo goed reageert op het aanzetten van toegankelijkheidscaching. Als caching uitstaat, wordt de toegankelijkheidsstructuur op aanvraag berekend en wordt de structuur na elke interactie verwijderd als het domein uitstaat. Als je het domein aanzet, slaat Chromium de berekende structuur op in het cachegeheugen.
Voor de JS DOM-traversal vragen we tijdens de traversal om de toegankelijke naam en rol voor elk element. Als caching uitstaat, berekent en verwijdert Chromium de toegankelijkheidsstructuur voor elk element dat we bezoeken. Bij de op CDP gebaseerde benaderingen wordt de boomstructuur alleen verwijderd tussen elke aanroep naar CDP, dus voor elke query. Deze benaderingen hebben ook baat bij het aanzetten van caching, omdat de toegankelijkheidsstructuur dan behouden blijft tussen CDP-aanroepen, maar de prestatieverbetering is daardoor relatief kleiner.
Hoewel het hier wenselijk lijkt om caching aan te zetten, brengt dit extra geheugengebruik met zich mee. Voor Puppeteer-scripts die bijvoorbeeld trace-bestanden opnemen, kan dit problematisch zijn. We hebben daarom besloten om caching van de toegankelijkheidsstructuur niet standaard aan te zetten. Gebruikers kunnen caching zelf aanzetten door het Accessibility-domein van het CDP aan te zetten.
Benchmark voor DevTools-testpakket
De vorige benchmark toonde aan dat de implementatie van ons querymechanisme op de CDP-laag een prestatieboost geeft in een klinisch unittestscenario.
We wilden weten of het verschil groot genoeg is om merkbaar te zijn in een realistischer scenario waarin een hele testsuite wordt uitgevoerd. Daarom hebben we de end-to-end testsuite van DevTools gepatcht om de op JavaScript en CDP gebaseerde prototypes te gebruiken en de runtimes te vergelijken. In deze benchmark hebben we in totaal 43 selectors gewijzigd van [aria-label=…] naar een aangepaste queryhandler aria/…, die we daarna hebben geïmplementeerd met elk van de prototypes.
Sommige selectors worden meerdere keren gebruikt in testscripts, dus het werkelijke aantal uitvoeringen van de aria-queryhandler was 113 per uitvoering van de suite. Het totale aantal queryselecties was 2253, dus slechts een fractie van de queryselecties vond plaats via de prototypes.
Zoals je in de afbeelding hierboven ziet, is er een duidelijk verschil in de totale runtime. De gegevens zijn te onduidelijk om iets specifieks te concluderen, maar het is duidelijk dat het prestatieverschil tussen de 2 prototypes ook in dit scenario zichtbaar is.
Een nieuw CDP-eindpunt
In het licht van de bovenstaande benchmarks en omdat de op startvlaggen gebaseerde aanpak over het algemeen ongewenst was, hebben we besloten een nieuwe CDP-opdracht te implementeren om de toegankelijkheidsstructuur op te vragen. Nu moesten we de interface van dit nieuwe eindpunt uitzoeken.
Voor onze use case in Puppeteer hebben we het eindpunt nodig om zogenaamde RemoteObjectIds als argument te gebruiken. Om de bijbehorende DOM-elementen daarna te kunnen vinden, moet het een lijst met objecten retourneren die de backendNodeIds voor de DOM-elementen bevatten.
Zoals je in het diagram hieronder kunt zien, hebben we verschillende benaderingen geprobeerd die aan deze interface voldoen. Hieruit bleek dat de grootte van de geretourneerde objecten, d.w.z. of we volledige toegankelijkheidsnodes of alleen de backendNodeIds retourneerden, geen merkbaar verschil maakte. Aan de andere kant vonden we dat het gebruik van de bestaande NextInPreOrderIncludingIgnored een slechte keuze was om de traversal-logica hier te implementeren, omdat dit een merkbare vertraging opleverde.
Afronding
Nu het CDP-eindpunt is ingesteld, hebben we de queryhandler geïmplementeerd aan de Puppeteer-kant. Het grootste deel van het werk was de code voor de verwerking van query's herstructureren, zodat query's rechtstreeks via CDP kunnen worden opgelost in plaats van query's via JavaScript die in de paginacontext worden geëvalueerd.
Wat is de volgende stap?
De nieuwe aria-handler is geleverd met Puppeteer v5.4.0 als ingebouwde queryhandler. We zijn benieuwd hoe gebruikers deze functie in hun testscripts gaan gebruiken en horen graag je ideeën over hoe we deze nog nuttiger kunnen maken.
De voorbeeldkanalen downloaden
Overweeg Chrome Canary, Dev of Bèta te gebruiken als je standaardbrowser voor ontwikkeling. Deze previewkanalen geven je toegang tot de nieuwste DevTools-functies, laten je geavanceerde webplatform-API's testen en helpen je problemen op je site te vinden voordat je gebruikers dat doen.
Contact opnemen met het Chrome DevTools-team
Gebruik de volgende opties om de nieuwe functies, updates of iets anders met betrekking tot DevTools te bespreken.
- Stuur feedback en functieverzoeken naar ons via crbug.com.
- Meld een DevTools-probleem via Meer opties > Hulp > Een DevTools-probleem melden in DevTools.
- Tweet naar @ChromeDevTools.
- Laat reacties achter bij YouTube-video's over Wat is er nieuw in DevTools of YouTube-video's met DevTools-tips.