Puppetaria: scripts Puppeteer com foco na acessibilidade

Johan Bay
Johan Bay

Puppeteer e a abordagem dele para seletores

O Puppeteer é uma biblioteca de automação de navegador para Node: ele permite controlar um navegador usando uma API JavaScript simples e moderna.

A tarefa mais importante do navegador é, claro, navegar em páginas da Web. Automatizar essa tarefa é basicamente automatizar as interações com a página da Web.

No Puppeteer, isso é feito consultando elementos DOM usando seletores baseados em string e realizando ações como clicar ou digitar texto nos elementos. Por exemplo, um script que abre developer.google.com, encontra a caixa de pesquisa e pesquisa puppetaria pode ser assim:

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

Portanto, a forma como os elementos são identificados usando seletores de consulta é uma parte importante da experiência do Puppeteer. Até agora, os seletores no Puppeteer eram limitados aos seletores de CSS e XPath que, embora sejam muito eficientes em termos de expressão, podem ter desvantagens para manter as interações do navegador em scripts.

Seletores sintáticos x semânticos

Os seletores de CSS são sintáticos por natureza. Eles estão intimamente ligados ao funcionamento interno da representação textual da árvore do DOM, no sentido de que fazem referência a IDs e nomes de classe do DOM. Por isso, eles são uma ferramenta essencial para desenvolvedores web modificarem ou adicionarem estilos a um elemento em uma página, mas, nesse contexto, o desenvolvedor tem controle total sobre a página e a árvore do DOM dela.

Por outro lado, um script do Puppeteer é um observador externo de uma página. Portanto, quando os seletores de CSS são usados nesse contexto, ele introduz suposições ocultas sobre como a página é implementada, sobre as quais o script do Puppeteer não tem controle.

O efeito é que esses scripts podem ser frágeis e suscetíveis a mudanças no código-fonte. Por exemplo, suponha que alguém use scripts do Puppeteer para testes automatizados de um aplicativo da Web que contém o nó <button>Submit</button> como o terceiro filho do elemento body. Um snippet de um caso de teste pode ser assim:

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

Aqui, estamos usando o seletor 'body:nth-child(3)' para encontrar o botão de envio, mas isso está estritamente vinculado a essa versão da página da Web. Se um elemento for adicionado acima do botão, esse seletor não vai mais funcionar.

Isso não é novidade para quem escreve testes: os usuários do Puppeteer já tentam escolher seletores que sejam robustos para essas mudanças. Com o Puppetaria, oferecemos aos usuários uma nova ferramenta para essa missão.

O Puppeteer agora vem com um gerenciador de consultas alternativo baseado na consulta da árvore de acessibilidade, em vez de depender de seletores de CSS. A filosofia subjacente aqui é que, se o elemento concreto que queremos selecionar não mudou, o nó de acessibilidade correspondente também não deve ter mudado.

Chamamos esses seletores de "seletores ARIA" e oferecemos suporte à consulta do nome acessível calculado e do papel da árvore de acessibilidade. Em comparação com os seletores de CSS, essas propriedades são semânticas por natureza. Eles não estão vinculados às propriedades sintáticas do DOM, mas são descritores de como a página é observada por tecnologias adaptativas, como leitores de tela.

No exemplo de script de teste acima, poderíamos usar o seletor aria/Submit[role="button"] para selecionar o botão desejado, em que Submit se refere ao nome acessível do elemento:

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

Agora, se decidirmos mudar o conteúdo de texto do botão de Submit para Done, o teste vai falhar novamente, mas, nesse caso, isso é desejável. Ao mudar o nome do botão, mudamos o conteúdo da página, em vez da apresentação visual ou da estrutura no DOM. Nossos testes devem nos alertar sobre essas mudanças para garantir que elas sejam intencionais.

Voltando ao exemplo maior com a barra de pesquisa, podemos usar o novo gerenciador aria e substituir

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

com

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

para localizar a barra de pesquisa.

De modo geral, acreditamos que o uso desses seletores ARIA pode oferecer os seguintes benefícios aos usuários do Puppeteer:

  • Tornar os seletores em scripts de teste mais resilientes a mudanças no código-fonte.
  • Tornar os scripts de teste mais legíveis (nomes acessíveis são descritores semânticos).
  • Incentivar boas práticas para atribuir propriedades de acessibilidade a elementos.

O restante deste artigo detalha como implementamos o projeto Puppetaria.

O processo de design

Contexto

Como motivado acima, queremos permitir a consulta de elementos pelo nome e papel acessíveis. Essas são propriedades da árvore de acessibilidade, uma dualidade da árvore do DOM comum, que é usada por dispositivos como leitores de tela para mostrar páginas da Web.

Ao analisar a especificação para calcular o nome acessível, fica claro que calcular o nome de um elemento não é uma tarefa simples. Por isso, decidimos desde o início reutilizar a infraestrutura do Chromium para isso.

Como abordamos a implementação

Mesmo que nos limitemos a usar a árvore de acessibilidade do Chromium, há várias maneiras de implementar a consulta ARIA no Puppeteer. Para entender o motivo, vamos ver como o Puppeteer controla o navegador.

O navegador expõe uma interface de depuração por um protocolo chamado Chrome DevTools Protocol (CDP). Isso expõe funcionalidades como "recarregar a página" ou "executar este trecho de JavaScript na página e retornar o resultado" por uma interface independente de linguagem.

Tanto o front-end do DevTools quanto o Puppeteer usam o CDP para se comunicar com o navegador. Para implementar comandos do CDP, há uma infraestrutura do DevTools em todos os componentes do Chrome: no navegador, no renderizador etc. O CDP se encarrega de encaminhar os comandos para o lugar certo.

As ações do Puppeteer, como consultar, clicar e avaliar expressões, são realizadas usando comandos do CDP, como Runtime.evaluate, que avalia o JavaScript diretamente no contexto da página e retorna o resultado. Outras ações do Puppeteer, como emular deficiência de visão de cores, fazer capturas de tela ou capturar traces, usam o CDP para se comunicar diretamente com o processo do renderizador do Blink.

CDP

Isso já nos deixa com dois caminhos para implementar nossa funcionalidade de consulta. Podemos:

  • Escrever nossa lógica de consulta em JavaScript e injetá-la na página usando Runtime.evaluate ou
  • Use um endpoint do CDP que possa acessar e consultar a árvore de acessibilidade diretamente no processo do Blink.

Implementamos três protótipos:

  • Travessia de DOM JS: com base na injeção de JavaScript na página.
  • Travessia da AXTree do Puppeteer: com base no uso do acesso atual do CDP à árvore de acessibilidade
  • Travessia do DOM do CDP: usando um novo endpoint do CDP criado especificamente para consultar a árvore de acessibilidade.

Travessia do DOM em JS

Esse protótipo faz uma travessia completa do DOM e usa element.computedName e element.computedRole, controlados pela flag de inicialização ComputedAccessibilityInfo, para recuperar o nome e o papel de cada elemento durante a travessia.

Travessia da AXTree do Puppeteer

Aqui, recuperamos a árvore de acessibilidade completa pelo CDP e a percorremos no Puppeteer. Os nós de acessibilidade resultantes são mapeados para nós DOM.

Travessia de DOM do CDP

Para esse protótipo, implementamos um novo endpoint do CDP especificamente para consultar a árvore de acessibilidade. Dessa forma, a consulta pode acontecer no back-end por uma implementação em C++ em vez de no contexto da página via JavaScript.

Comparativo de teste de unidade

A figura a seguir compara o tempo de execução total de consulta de quatro elementos 1.000 vezes para os três protótipos. O comparativo de mercado foi executado em três configurações diferentes, variando o tamanho da página e se o armazenamento em cache de elementos de acessibilidade estava ativado ou não.

Comparativo de mercado: tempo de execução total da consulta de quatro elementos 1.000 vezes

É evidente que há uma diferença considerável de desempenho entre o mecanismo de consulta baseado no CDP e os outros dois implementados apenas no Puppeteer, e a diferença relativa parece aumentar drasticamente com o tamanho da página. É interessante ver que o protótipo de travessia do DOM em JS responde tão bem à ativação do cache de acessibilidade. Com o armazenamento em cache desativado, a árvore de acessibilidade é calculada sob demanda e descartada após cada interação se o domínio estiver desativado. Ativar o domínio faz com que o Chromium armazene em cache a árvore calculada.

Para a travessia do DOM do JS, pedimos o nome acessível e o papel de cada elemento durante a travessia. Portanto, se o armazenamento em cache estiver desativado, o Chromium vai calcular e descartar a árvore de acessibilidade de cada elemento que visitamos. Já nas abordagens baseadas em CDP, a árvore é descartada apenas entre cada chamada para o CDP, ou seja, para cada consulta. Essas abordagens também se beneficiam da ativação do armazenamento em cache, já que a árvore de acessibilidade é mantida em todas as chamadas do CDP, mas o aumento de desempenho é comparativamente menor.

Embora a ativação do armazenamento em cache pareça desejável aqui, ela tem um custo de uso da memória extra. Para scripts do Puppeteer que, por exemplo, registram arquivos de trace, isso pode ser um problema. Por isso, decidimos não ativar o armazenamento em cache da árvore de acessibilidade por padrão. Os usuários podem ativar o armazenamento em cache ativando o domínio de acessibilidade do CDP.

Comparativo de mercado do pacote de testes do DevTools

O comparativo de mercado anterior mostrou que a implementação do nosso mecanismo de consulta na camada do CDP aumenta o desempenho em um cenário de teste de unidade clínica.

Para ver se a diferença é pronunciada o suficiente para ser perceptível em um cenário mais realista de execução de um pacote de testes completo, corrigimos o pacote de testes de ponta a ponta do DevTools para usar os protótipos baseados em JavaScript e CDP e comparamos os tempos de execução. Nesse comparativo, mudamos um total de 43 seletores de [aria-label=…] para um gerenciador de consultas personalizado aria/…, que implementamos usando cada um dos protótipos.

Alguns dos seletores são usados várias vezes em scripts de teste, então o número real de execuções do gerenciador de consultas aria foi de 113 por execução do pacote. O número total de seleções de consulta foi 2.253, então apenas uma fração delas aconteceu pelos protótipos.

Comparativo de mercado: pacote de testes e2e

Como mostrado na figura acima, há uma diferença perceptível no tempo de execução total. Os dados têm muito ruído para que seja possível concluir algo específico, mas fica claro que a diferença de desempenho entre os dois protótipos também aparece nesse cenário.

Um novo endpoint do CDP

Considerando os comparativos de mercado acima e como a abordagem baseada em flag de lançamento não era desejável em geral, decidimos implementar um novo comando do CDP para consultar a árvore de acessibilidade. Agora, precisamos descobrir a interface desse novo endpoint.

Para nosso caso de uso no Puppeteer, precisamos que o endpoint use os chamados RemoteObjectIds como argumento e, para que possamos encontrar os elementos DOM correspondentes depois, ele precisa retornar uma lista de objetos que contenha o backendNodeIds para os elementos DOM.

Como mostra o gráfico abaixo, testamos várias abordagens que atendem a essa interface. Com isso, descobrimos que o tamanho dos objetos retornados, ou seja, se retornamos nós de acessibilidade completos ou apenas o backendNodeIds, não fez diferença perceptível. Por outro lado, descobrimos que usar o NextInPreOrderIncludingIgnored atual era uma escolha ruim para implementar a lógica de travessia aqui, já que isso resultava em uma lentidão perceptível.

Comparativo de mercado: comparação de protótipos de travessia de AXTree baseados em CDP

Conclusão

Agora, com o endpoint do CDP em vigor, implementamos o gerenciador de consultas no lado do Puppeteer. A maior parte do trabalho foi reestruturar o código de processamento de consultas para que elas sejam resolvidas diretamente pelo CDP, em vez de serem feitas pelo JavaScript avaliado no contexto da página.

A seguir

O novo gerenciador aria foi enviado com o Puppeteer v5.4.0 como um gerenciador de consultas integrado. Estamos ansiosos para ver como os usuários vão adotar esse recurso nos scripts de teste e queremos saber suas ideias para torná-lo ainda mais útil.

Baixar os canais de pré-lançamento

Considere usar o Chrome Canary, Dev ou Beta como seu navegador de desenvolvimento padrão. Esses canais de pré-lançamento dão acesso aos recursos mais recentes do DevTools, permitem testar APIs de ponta da plataforma da Web e ajudam a encontrar problemas no seu site antes dos usuários.

Entre em contato com a equipe do Chrome DevTools

Use as opções a seguir para discutir os novos recursos, atualizações ou qualquer outro assunto relacionado ao DevTools.