Pular para o conteúdo
Voltar aos Artigos Publicados
Cypress

Seletores virtualizados e overlays que ignoram seus cliques

Por que o cy.click() passa sem fazer nada em alguns componentes — guards de isTrusted, hit-testing em canvas, overlays em portal, listas virtualizadas — e o que funciona no lugar.

Seletores virtualizados e overlays que ignoram seus cliques
Conteúdo 100% autoral: Todos os artigos, desafios práticos e soluções em código foram escritos por Marcelo Costa sem qualquer uso de IA na redação. A inteligência artificial foi utilizada exclusivamente no auxílio à tradução para múltiplos idiomas.

Você já viu o cy.click() ficar verde no command log (elemento encontrado, visível, não coberto, evento disparado, Cypress completamente satisfeito) enquanto a aplicação na tela não fazia absolutamente nada? Quem nunca!

Esse é mais traiçoeiro do que um seletor errado, porque o log fica verde até a asserção. Aí você começa a desconfiar da asserção, depois do timing, depois dos dados de teste, depois do framework, mais ou menos nessa ordem. O clique fica por último. Deveria vir primeiro.

Vivo esbarrando em quatro causas para isso, mais uma quinta bem curta. Então vamos colocar o clique no banco dos réus, uma causa de cada vez, com o que realmente funciona em cada uma.

O componente só escuta humanos

1. O componente verifica event.isTrusted

O clique padrão do Cypress é um evento sintético, disparado via JavaScript. E os navegadores percebem: sintético → isTrusted: false, input real do usuário → true. Qualquer componente que se proteja com essa checagem vai ignorar você, educadamente e em silêncio:

function onBackdropClick(e) {
  if (!e.isTrusted) return
  close()
}

De onde vem um guard desses? De componentes que precisaram se defender de eventos disparando em loop, de bibliotecas que se blindam contra input gerado por script e (meu favorito) de código onde alguém o adicionou para corrigir um bug e nunca documentou o motivo. Não dá para argumentar com isso de dentro da página. Sintético é sintético.

Então pare de disparar eventos e passe a controlar o navegador. O cypress-real-events envia o input pelo Chrome DevTools Protocol, então quem gera o evento é o próprio navegador, e ele é trusted:

cy.get('[data-cy="save"]').realClick()

O mesmo vale para realHover, realType e realPress. Agora, por mais tentador que seja, não saia espalhando isso em todo lugar por reflexo. Minha regra: use .click() por padrão e recorra ao .realClick() assim que um clique for um no-op confirmado, só naquele elemento. O porém: ele pula as verificações de actionability do Cypress e só funciona em navegadores baseados em Chromium.

A célula que acendeu e mentiu

2. O alvo é um canvas que faz o próprio hit-testing

Grids no estilo planilha, bibliotecas de gráficos e editores em WebGL ou canvas não têm elementos DOM para suas células. Eles têm um único <canvas> e um mapa interno de coordenadas para células. Não há nada para selecionar, e um trigger('mousedown') no elemento canvas não leva a lugar útil nenhum: esses componentes fazem hit-testing a partir das coordenadas do evento e muitas vezes dependem da sequência completa e trusted de eventos de ponteiro, e não de um único evento que você consegue forjar.

O sintoma é curiosamente específico. A interação parece ter funcionado (uma célula fica destacada) e depois aquilo de que você precisava simplesmente não acontece. Em um componente de planilha com o qual briguei, foi assim:

Clique em uma célula → ela fica visualmente selecionada → a barra de fórmulas nunca recebe uma célula alvo → todos os passos seguintes operam sobre nada → erro nenhum.

Parece selecionada. Não está.

Duas coisas te tiram dessa:

Clique por coordenada, de verdade. Calcule a posição relativa ao canto superior esquerdo do canvas, que é o que as opções x e y do realClick esperam, e dispare um clique via CDP nesse ponto:

function realClickCell(row: number, col: number) {
  cy.get('canvas.grid-canvas').realClick({
    x: HEADER_W + col * CELL_W + CELL_W / 2,
    y: HEADER_H + row * CELL_H + CELL_H / 2,
  })
}

Depois, verifique o estado interno, não os pixels. O problema todo era que o feedback visual mentia. Se o componente expõe um objeto de api, faça a asserção nele ("a célula ativa é B4") e você pega o no-op silencioso no momento em que ele acontece, e não três passos depois.

E, já que você está aí, cuidado com offsets hardcoded. Qualquer helper com um +3 colunas ou +2 linhas mágico embutido para compensar painéis congelados vai dar errado, sem avisar, assim que rodar em algum lugar sem esses painéis: um diálogo, um preview, uma visão de drill-down. Se você precisa mesmo do offset, transforme-o em um parâmetro nomeado com valor padrão, e não em um literal no meio da conta.

Endereço errado, sem saída 🚪

3. O overlay é renderizado via portal em outro lugar do DOM

Dropdowns, date pickers, multi-selects e tooltips normalmente são renderizados em um container no final do <body>, e não dentro do componente que os abriu. Então, isto:

cy.get('[data-cy="region-select"]').click().find('.option').contains('EMEA').click()

falha, porque as opções não são descendentes do select. Em vez disso, restrinja a busca ao container do overlay:

cy.get('[data-cy="region-select"]').click()
cy.get('.cdk-overlay-container').contains('.option', 'EMEA').click()

E aí vem a hora de fechá-los. Topei com um overlay de multi-select que não fechava por nenhum meio legítimo:

  • clicar no backdrop → ignorado (um guard de isTrusted, olá de novo)
  • {esc} → passou direto pelo overlay e fechou o diálogo pai no lugar dele
  • realClick no backdrop → caiu em uma região transparente que engoliu o evento

A seleção já tinha sido gravada na alteração, então o estado que realmente me importava estava correto antes de fechar. A resposta pragmática foi remover o overlay e o backdrop dele do DOM. Um diálogo pai construído sobre o CDK tem pane e backdrop próprios no mesmo container, então pegue o último de cada. Eles pertencem ao overlay aberto mais recentemente:

cy.get('.cdk-overlay-pane').last().invoke('remove')
cy.get('.cdk-overlay-backdrop').last().invoke('remove')

É gambiarra? É. Por isso quero ser cuidadoso na forma de recomendar, porque é um trade-off real: você deixa de testar que o overlay fecha. Use essa saída só quando o comportamento sob teste já estiver verificado e o overlay estiver atrapalhando o próximo passo, e deixe uma nota na spec dizendo de qual comportamento você abriu mão. A alternativa, apagar o teste ou deixá-lo vermelho para sempre, verifica estritamente menos. E abra o bug: um painel que um usuário real consegue fechar e uma ferramenta automatizada não costuma estar a um guard de isTrusted de também ser um problema de acessibilidade.

O item que ainda não está lá

4. A lista é virtualizada, então o item não está lá

Listas longas de membros, combo boxes com milhares de entradas, seletores virtualizados: só a janela visível existe no DOM, exatamente como em um grid de dados virtualizado. Dois hábitos quebram na hora:

Clicar por posição → .eq(12) aponta para o décimo terceiro item renderizado, que não é o décimo terceiro item. Assim que a lista rola, ou a altura da linha muda, você está clicando em outra coisa. E a altura dos itens realmente varia entre ambientes com configurações de densidade ou fontes diferentes.

Clicar em .active → o item destacado é o último em que o cursor de teclado do componente encostou. Não é o seu item.

Então filtre primeiro e depois selecione pelo conteúdo:

function selectMember(filter: string, match: string | RegExp = filter) {
  cy.get('[data-cy="member-filter"]').clear().type(filter)
  cy.get('.member-list').contains('.member-item', match).click()
}

Digitar no filtro é o que faz o item existir. Depois, faça o match pelo texto e use uma regex quando precisar de match exato entre nomes em que um é substring do outro. V3 e V3.1 na mesma lista vão te pegar, porque o contains fica com o primeiro match, que pode não ser o que você queria.

Mais duas, rapidinho. Se você precisar rolar em vez de filtrar, use a api de "scroll into view" do próprio componente, não offsets em pixels. E quando a lista tiver rótulos duplicados, pare de tentar identificar linhas só pelo rótulo: use um par como chave, como rótulo mais um badge ou um chip de tipo, e faça a asserção sobre o par.

Bônus: o botão que muda de ideia

5. O botão gatilho mudou depois da primeira abertura (uma curta)

Sutil, mas pode doer. Algumas toolbars mudam os próprios controles quando um painel está aberto: o botão "mais ações" vira um botão "excluir", ou um floating action button troca de ícone e de handler.

Então um helper que "abre o painel" funciona na primeira vez. Segunda chamada → ele clica em uma ação completamente diferente. Se você tiver sorte, ele falha. Se não tiver, ele apaga alguma coisa.

Abra uma vez e mantenha aberto durante os passos que precisam dele. Se um fluxo realmente precisar de uma segunda abertura, escreva um helper separado para isso, que aponte para o controle alterado pelo nome. Não deixe um único helper "esperto" o bastante para lidar com os dois casos, ou você terá escondido a armadilha em vez de removê-la.


Basicamente, com isso tudo no lugar, um clique verde significa que o clique realmente chegou. E quando não chegou, a spec fica vermelha exatamente nesse passo, e não três passos depois, numa asserção que nunca foi o problema.

Bons testes!