Puppetaria: script di Puppeteer orientati all'accessibilità

Johan Bay
Johan Bay

Puppeteer e il suo approccio ai selettori

Puppeteer è una libreria di automazione del browser per Node: ti consente di controllare un browser utilizzando un'API JavaScript semplice e moderna.

L'attività più importante del browser è, ovviamente, la navigazione nelle pagine web. Automatizzare questa attività equivale essenzialmente ad automatizzare le interazioni con la pagina web.

In Puppeteer, questo risultato si ottiene eseguendo query sugli elementi DOM utilizzando selettori basati su stringhe ed eseguendo azioni come fare clic o digitare testo sugli elementi. Ad esempio, uno script che apre developer.google.com, trova la casella di ricerca e cerca puppetaria potrebbe avere il seguente aspetto:

(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.$(>'d>evsite-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');
 })();

Il modo in cui gli elementi vengono identificati utilizzando i selettori di query è quindi una parte fondamentale dell'esperienza di Puppeteer. Finora, i selettori in Puppeteer erano limitati ai selettori CSS e XPath che, sebbene molto potenti dal punto di vista espressivo, possono presentare svantaggi per la persistenza delle interazioni del browser negli script.

Selettori sintattici e semantici

I selettori CSS sono di natura sintattica; sono strettamente legati al funzionamento interno della rappresentazione testuale dell'albero DOM, nel senso che fanno riferimento agli ID e ai nomi delle classi del DOM. In quanto tali, forniscono uno strumento fondamentale per gli sviluppatori web per modificare o aggiungere stili a un elemento di una pagina, ma in questo contesto lo sviluppatore ha il controllo completo sulla pagina e sul relativo albero DOM.

D'altra parte, uno script Puppeteer è un osservatore esterno di una pagina, quindi quando i selettori CSS vengono utilizzati in questo contesto, introducono presupposti nascosti su come viene implementata la pagina, su cui lo script Puppeteer non ha alcun controllo.

L'effetto è che questi script possono essere fragili e suscettibili alle modifiche del codice sorgente. Supponiamo, ad esempio, che un utente utilizzi script Puppeteer per i test automatici di un'applicazione web contenente il nodo <button>Submit</button> come terzo elemento secondario dell'elemento body. Uno snippet di uno scenario di test potrebbe avere il seguente aspetto:

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

In questo caso, utilizziamo il selettore 'body:nth-child(3)' per trovare il pulsante di invio, ma questo è strettamente legato esattamente a questa versione della pagina web. Se in un secondo momento viene aggiunto un elemento sopra il pulsante, questo selettore non funziona più.

Questa non è una novità per chi scrive i test: gli utenti di Puppeteer cercano già di scegliere selettori che siano resistenti a queste modifiche. Con Puppetaria, offriamo agli utenti un nuovo strumento per questa ricerca.

Puppeteer ora include un gestore di query alternativo basato sull'esecuzione di query sull'albero di accessibilità anziché sui selettori CSS. La filosofia di base è che se l'elemento concreto che vogliamo selezionare non è cambiato, non dovrebbe essere cambiato nemmeno il nodo di accessibilità corrispondente.

Questi selettori sono chiamati "selettori ARIA" e supportano l'esecuzione di query per il nome accessibile calcolato e il ruolo dell'albero di accessibilità. Rispetto ai selettori CSS, queste proprietà sono di natura semantica. Non sono legati alle proprietà sintattiche del DOM, ma sono descrittori di come la pagina viene osservata tramite tecnologie per la disabilità come gli screen reader.

Nell'esempio di script di test riportato sopra, potremmo invece utilizzare il selettore aria/Submit[role="button"] per selezionare il pulsante desiderato, dove Submit si riferisce al nome accessibile dell'elemento:

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

Ora, se in un secondo momento decidiamo di modificare il contenuto di testo del nostro pulsante da Submit a Done, il test non andrà di nuovo a buon fine, ma in questo caso è auspicabile; modificando il nome del pulsante, modifichiamo i contenuti della pagina, anziché la sua presentazione visiva o il modo in cui è strutturata nel DOM. I nostri test dovrebbero avvisarci di queste modifiche per garantire che siano intenzionali.

Tornando all'esempio più grande con la barra di ricerca, potremmo sfruttare il nuovo gestore aria e sostituire

const search = await page.$('devsite-sea>rch  f>orm  div.devsite-search-container');

con

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

per trovare la barra di ricerca.

Più in generale, riteniamo che l'utilizzo di questi selettori ARIA possa offrire i seguenti vantaggi agli utenti di Puppeteer:

  • Rendi i selettori negli script di test più resilienti alle modifiche del codice sorgente.
  • Rendere gli script di test più leggibili (i nomi accessibili sono descrittori semantici).
  • Incoraggiare le best practice per l'assegnazione di proprietà di accessibilità agli elementi.

Il resto di questo articolo approfondisce i dettagli su come abbiamo implementato il progetto Puppetaria.

Il processo di progettazione

Sfondo

Come spiegato in precedenza, vogliamo consentire l'esecuzione di query sugli elementi in base al nome e al ruolo accessibili. Queste sono proprietà dell'albero di accessibilità, un duale del solito albero DOM, utilizzato da dispositivi come gli screen reader per mostrare le pagine web.

Dalla specifica per il calcolo del nome accessibile, è chiaro che il calcolo del nome di un elemento è un'attività non banale, quindi fin dall'inizio abbiamo deciso di riutilizzare l'infrastruttura esistente di Chromium per questo scopo.

Come abbiamo affrontato l'implementazione

Anche limitandoci a utilizzare l'albero di accessibilità di Chromium, esistono diversi modi per implementare le query ARIA in Puppeteer. Per capire il motivo, vediamo innanzitutto come Puppeteer controlla il browser.

Il browser espone un'interfaccia di debug tramite un protocollo chiamato Chrome DevTools Protocol (CDP). In questo modo, vengono esposte funzionalità come "ricarica la pagina" o "esegui questo snippet di JavaScript nella pagina e restituisci il risultato" tramite un'interfaccia indipendente dal linguaggio.

Sia il frontend di DevTools sia Puppeteer utilizzano CDP per comunicare con il browser. Per implementare i comandi CDP, esiste un'infrastruttura DevTools all'interno di tutti i componenti di Chrome: nel browser, nel renderer e così via. CDP si occupa di indirizzare i comandi alla posizione corretta.

Le azioni di Puppeteer, come l'esecuzione di query, i clic e la valutazione delle espressioni, vengono eseguite sfruttando i comandi CDP come Runtime.evaluate, che valuta JavaScript direttamente nel contesto della pagina e restituisce il risultato. Altre azioni di Puppeteer, come l'emulazione della discromatopsia, l'acquisizione di screenshot o l'acquisizione di tracce, utilizzano CDP per comunicare direttamente con il processo di rendering di Blink.

CDP

Questo ci lascia già con due percorsi per implementare la nostra funzionalità di query; possiamo:

  • Scrivere la logica di query in JavaScript e inserirla nella pagina utilizzando Runtime.evaluate oppure
  • Utilizza un endpoint CDP in grado di accedere ed eseguire query sull'albero di accessibilità direttamente nel processo Blink.

Abbiamo implementato tre prototipi:

  • Attraversamento DOM JS: basato sull'inserimento di JavaScript nella pagina
  • Attraversamento AXTree di Puppeteer: basato sull'utilizzo dell'accesso CDP esistente all'albero di accessibilità
  • Attraversamento DOM CDP: utilizza un nuovo endpoint CDP creato appositamente per eseguire query sull'albero di accessibilità

JS DOM traversal

Questo prototipo esegue un attraversamento completo del DOM e utilizza element.computedName e element.computedRole, controllati dal flag di lancio ComputedAccessibilityInfo, per recuperare il nome e il ruolo di ogni elemento durante l'attraversamento.

Attraversamento AXTree di Puppeteer

In questo caso, invece, recuperiamo l'intero albero di accessibilità tramite CDP e lo attraversiamo in Puppeteer. I nodi di accessibilità risultanti vengono poi mappati ai nodi DOM.

Attraversamento DOM CDP

Per questo prototipo, abbiamo implementato un nuovo endpoint CDP specifico per l'esecuzione di query sull'albero di accessibilità. In questo modo, l'esecuzione delle query può avvenire nel backend tramite un'implementazione C++ anziché nel contesto della pagina tramite JavaScript.

Benchmark dei test delle unità

La figura seguente confronta il tempo di esecuzione totale dell'esecuzione di query su quattro elementi 1000 volte per i tre prototipi. Il benchmark è stato eseguito in tre diverse configurazioni, variando le dimensioni della pagina e l'attivazione o meno della memorizzazione nella cache degli elementi di accessibilità.

Benchmark: tempo di esecuzione totale per l'esecuzione di query su quattro elementi 1000 volte

È evidente che esiste un divario di prestazioni considerevole tra il meccanismo di query supportato da CDP e gli altri due implementati esclusivamente in Puppeteer, e la differenza relativa sembra aumentare notevolmente con le dimensioni della pagina. È interessante notare che il prototipo di attraversamento del DOM JS risponde così bene all'attivazione della memorizzazione nella cache dell'accessibilità. Se la memorizzazione nella cache è disattivata, l'albero di accessibilità viene calcolato on demand e viene eliminato dopo ogni interazione se il dominio è disattivato. L'abilitazione del dominio fa sì che Chromium memorizzi nella cache l'albero calcolato.

Per l'esplorazione del DOM JS, richiediamo il nome e il ruolo accessibili per ogni elemento durante l'esplorazione, quindi se la memorizzazione nella cache è disattivata, Chromium calcola e ignora l'albero di accessibilità per ogni elemento che visitiamo. Per gli approcci basati su CDP, invece, l'albero viene eliminato solo tra ogni chiamata a CDP, ovvero per ogni query. Questi approcci traggono vantaggio anche dall'attivazione della memorizzazione nella cache, poiché l'albero di accessibilità viene mantenuto tra le chiamate CDP, ma il miglioramento delle prestazioni è quindi relativamente inferiore.

Anche se l'attivazione della memorizzazione nella cache sembra auspicabile in questo caso, comporta un costo aggiuntivo per la memoria utilizzata. Per gli script Puppeteer che, ad esempio, registrano file di traccia, questo potrebbe essere problematico. Abbiamo quindi deciso di non attivare la memorizzazione nella cache dell'albero di accessibilità per impostazione predefinita. Gli utenti possono attivare la memorizzazione nella cache autonomamente attivando il dominio di accessibilità CDP.

Benchmark della suite di test di DevTools

Il benchmark precedente ha dimostrato che l'implementazione del nostro meccanismo di query a livello di CDP offre un aumento delle prestazioni in uno scenario di test unitario clinico.

Per verificare se la differenza è abbastanza evidente da essere percepibile in uno scenario più realistico di esecuzione di una suite di test completa, abbiamo applicato una patch alla suite di test end-to-end di DevTools per utilizzare i prototipi basati su JavaScript e CDP e abbiamo confrontato i tempi di esecuzione. In questo benchmark, abbiamo modificato un totale di 43 selettori da [aria-label=…] a un gestore di query personalizzato aria/…, che abbiamo poi implementato utilizzando ciascuno dei prototipi.

Alcuni selettori vengono utilizzati più volte negli script di test, quindi il numero effettivo di esecuzioni del gestore delle query aria è stato di 113 per ogni esecuzione della suite. Il numero totale di selezioni di query è stato 2253, quindi solo una parte delle selezioni di query è avvenuta tramite i prototipi.

Benchmark: suite di test e2e

Come mostrato nella figura precedente, esiste una differenza evidente nel tempo di esecuzione totale. I dati sono troppo confusi per trarre conclusioni specifiche, ma è chiaro che il divario di prestazioni tra i due prototipi si manifesta anche in questo scenario.

Un nuovo endpoint CDP

Alla luce dei benchmark sopra riportati e poiché l'approccio basato sui flag di lancio non era auspicabile in generale, abbiamo deciso di procedere con l'implementazione di un nuovo comando CDP per eseguire query sull'albero di accessibilità. Ora dovevamo capire l'interfaccia di questo nuovo endpoint.

Per il nostro caso d'uso in Puppeteer, l'endpoint deve accettare i cosiddetti RemoteObjectIds come argomento e, per consentirci di trovare gli elementi DOM corrispondenti in un secondo momento, deve restituire un elenco di oggetti che contiene i backendNodeIds per gli elementi DOM.

Come mostrato nel grafico di seguito, abbiamo provato diversi approcci che soddisfano questa interfaccia. Da questo abbiamo scoperto che le dimensioni degli oggetti restituiti, ovvero se abbiamo restituito o meno nodi di accessibilità completi o solo backendNodeIds, non facevano alcuna differenza evidente. D'altra parte, abbiamo scoperto che l'utilizzo dell'esistente NextInPreOrderIncludingIgnored era una scelta sbagliata per l'implementazione della logica di attraversamento, in quanto ha comportato un notevole rallentamento.

Benchmark: confronto dei prototipi di attraversamento AXTree basati su CDP

Riepilogo

Ora, con l'endpoint CDP attivo, abbiamo implementato il gestore delle query sul lato Puppeteer. Il lavoro più impegnativo è stato ristrutturare il codice di gestione delle query per consentire la risoluzione delle query direttamente tramite CDP anziché tramite JavaScript valutato nel contesto della pagina.

Passaggi successivi

Il nuovo gestore aria è stato fornito con Puppeteer v5.4.0 come gestore di query integrato. Non vediamo l'ora di vedere come gli utenti lo adotteranno nei loro script di test e di ascoltare le tue idee su come possiamo renderlo ancora più utile.

Scarica i canali in anteprima

Valuta la possibilità di utilizzare Chrome Canary, Dev o Beta come browser di sviluppo predefinito. Questi canali di anteprima ti consentono di accedere alle funzionalità più recenti di DevTools, di testare le API della piattaforma web all'avanguardia e di trovare i problemi sul tuo sito prima che lo facciano gli utenti.

Contatta il team di Chrome DevTools

Utilizza le seguenti opzioni per discutere di nuove funzionalità, aggiornamenti o qualsiasi altro argomento relativo a DevTools.