When Can The Test Click

How to wait for the application to attach event listeners before calling the cy.click command.

Many many moons ago I wrote a blog post When Can The Test Click?. It was part of a series of blog posts that explain why an end-to-end test might be flaky. The main reason in my opinion that causes a test to sometimes not work, is that the test runner does not know when the application is ready to receive test commands, like click. There might be many reasons why that is the case, but one of the hardest to reliably solve is knowing when the application has finished its initial load. Is the application ready to receive the user click? Or does it need extra 100ms to finish loading and starting its JavaScript code? I have discussed a possible solution in the blog post When Has The App Loaded. In this blog post, I will show another solution that goes directly to the heart of the problem of "missing" clicks - checking if the application has attached its event listeners to the button before clicking on it.

The problem

Let's take an application that responds to a button click event.

index.html
1
2
3
4
5
<body>
<button id="one">Click me</button>
<output id="output"></output>
<script src="app.js"></script>
</body>
app.js
1
2
3
4
5
const btn = document.querySelector('#one')
const output = document.querySelector('#output')
btn.addEventListener('click', () => {
output.innerText = 'clicked'
})

The above application registers the event handler synchronously. When the cy.visit command finishes, the application is ready to go.

cypress/integration/spec1.js
1
2
3
4
5
it('clicks on the button', () => {
cy.visit('public/index.html')
cy.get('button#one').click()
cy.contains('#output', 'clicked')
})

The test reliably passes.

The test can immediately click on the button

But what if the application delays its load? What if it attaches the "click" event listener by one second? What if the application is slowly loading chunks of its code? While the user sees the page elements, they are not ready to process any events, and the test fails.

app.js
1
2
3
4
5
6
7
8
9
const btn = document.querySelector('#one')
const output = document.querySelector('#output')

// add event listeners after a short delay
setTimeout(() => {
btn.addEventListener('click', () => {
output.innerText = 'clicked'
})
}, 1000)

The same test now fails - because the "click" even gets lost. The test runner clicks before the application starts listening.

The test fails because it clicks before the application registers the click event handler

The diagnosis

A good way to determine if the test acts before the application is ready to respond is to add cy.wait commands. You can wait for some external observable event, or simply wait N milliseconds (I know, I know, but this is temporary).

cypress/integration/spec1.js
1
2
3
4
5
6
7
it('clicks on the button', () => {
cy.visit('public/index.html')
// let the application fully load
cy.wait(5000)
cy.get('button#one').click()
cy.contains('#output', 'clicked')
})

The test now reliably passes

The test waits five seconds before clicking

How can we avoid the hard-coded wait, and instead click after the application is ready to receive the "click" event?

Event listeners

If you open the DevTools and look at the button DOM element, the tab "Event Listeners" shows the currently attached ... event listeners.

The button element has the click event listener attached

This "click" event listener is attached to the element by the application code after it is done initializing.

cypress-cdp

Hmm, if only our test could wait for this event listener to exist before clicking... Well, let's check! If the DevTools panel can query an element to show its event listeners, we can do the same thing from Cypress using Chrome Debugger Protocol. After all, Cypress has this connection already and even exposes it to the test code, as I show in my blog post Cypress vs Other Test Runners. In general, this looks like

1
2
3
4
Cypress.automation('remote:debugger:protocol', {
command: rdpCommand,
params,
})

Because Cypress.automation is a very low-level primitive, it eagerly returns a Promise, which your code needs to cy.wrap(Promise) to wait to resolve. But since I am a nice person, I wrote a little NPM plugin that exposes the Chrome Debugger Protocol via a Cypress custom command cy.CDP. You can find the source code in the repo bahmutov/cypress-cdp.

1
$ npm i -D cypress-cdp

In your support or spec file, import the cypress-cdp and you will get the cy.CDP command. Let's use it to wait for the event listeners to be attached. First, we need to find the internal browser object ID for the button we want to click. We can ask the Runtime object to evaluate the expression in the application's iframe:

cypress/integration/click.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import 'cypress-cdp'

it('clicks on the button when there is an event handler', () => {
cy.visit('public/index.html')

const selector = 'button#one'
cy.CDP('Runtime.evaluate', {
expression: 'frames[0].document.querySelector("' + selector + '")',
})
.should((v) => {
expect(v.result).to.have.property('objectId')
})
.its('result.objectId')
.then(cy.log)
})

The command cy.CDP has DOM snapshots and checks the attached assertions, so it is meant to be retry-able. You can see the button's internal object ID printed in the Command Log.

We got the internal button ID from the browser using the Chrome Debugger Protocol

Now that we have the ID, let's query the event listeners - and because the cy.CDP command retries the assertions that follow it, we can check the returned event listeners array. If there are no event listeners (we could also check their types), then we can throw an error to re-query the element.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
it('clicks on the button when there is an event handler', () => {
cy.visit('public/index.html')

const selector = 'button#one'
cy.CDP('Runtime.evaluate', {
expression: 'frames[0].document.querySelector("' + selector + '")',
})
.should((v) => {
expect(v.result).to.have.property('objectId')
})
.its('result.objectId')
.then(cy.log)
.then((objectId) => {
cy.CDP('DOMDebugger.getEventListeners', {
objectId,
depth: -1,
pierce: true,
}).should((v) => {
expect(v.listeners).to.have.length.greaterThan(0)
})
})
// now we can click that button
cy.get(selector).click()
})

Does it work? Let's see:

The test waits for the event listener to be attached before clicking

Beautiful!

Tip: you can find the full list of Chrome Debugger Protocol commands here.

Update 1: example application

Just as I wrote this blog post, I hit a real-world use case for waiting for the "click" event listener to be attached, read my blog post Solve The First Click.