Cypress Automation

Using Chrome Debugger Protocol from Cypress

When Cypress controls a Chromium browser, it has an open remote interface connection between Cypress and the browser. Typically, Cypress uses it to visit the site and perform special operations like setting cookies, or setting the file downloads folder. In this blog post I will show how to use Cypress.automation command to set the browser permission and to take native screenshot images.

Set the browser permission

This code example comes from the recipe in the cypress-example-recipes repo.

If we want to access the clipboard from the test, the browser asks the user for permission. The test can always query the current permission

1
2
3
4
5
6
7
8
9
10
11
12
13
it('can be queried in Chrome', { browser: 'chrome' }, () => {
cy.visit('index.html') // yields the window object
.its('navigator.permissions')
// permission names taken from
// https://w3c.github.io/permissions/#enumdef-permissionname
.invoke('query', { name: 'clipboard-read' })
// by default it is "prompt" which shows a popup asking
// the user if the site can have access to the clipboard
// if the user allows, then next time it will be "granted"
// If the user denies access to the clipboard, on the next
// run the state will be "denied"
.its('state').should('be.oneOf', ['prompt', 'granted', 'denied'])
})

If we look at the Chrome Debugger Protocol, we can see that there is a way to call a command to set the permission using Browser.setPermission command. By granting the test runner the permission, the browser skips showing the "should this site have access to the clipboard?" user prompt.

Browser.setPermission command

To call this command from the Cypress test, use Cypress.automation command:

1
2
3
4
5
6
7
8
9
10
11
// only the Chrome CDP is supported
// thus the first argument is always "remote:debugger:protocol"
Cypress.automation('remote:debugger:protocol', {
command: 'Browser.grantPermissions',
params: {
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
// make the permission tighter by allowing the current origin only
// like "http://localhost:56978"
origin: window.location.origin,
},
})

The promise-returning Cypress.automation command is very low-level, thus it is NOT automatically inserted into the Cypress test command chain. To make the test "wait" for the promise to resolve, use the cy.wrap command:

1
2
3
4
5
6
7
8
9
cy.wrap(Cypress.automation('remote:debugger:protocol', {
command: 'Browser.grantPermissions',
params: {
permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'],
// make the permission tighter by allowing the current origin only
// like "http://localhost:56978"
origin: window.location.origin,
},
}))

Tip: if you want to run the automation command after other Cypress commands, make sure to return the the Cypress.automation(...) promise from the .then callback; we will see such example in the next section.

You can watch setting the browser permission in this video below:

Saving native screenshots

This example comes from the bahmutov/monalego repo.

If you want to save the application page without any visual artifacts introduced by cy.screenshot command, you can use the Page.captureScreenshot CDP command.

Page.captureScreenshot command

Let's say we want to capture the screenshot after a two second delay. We place the Cypress.automation inside a .then callback after the cy.wait command. The callback automatically waits for the returned promise to resolve.

1
2
3
4
5
6
7
8
9
10
11
12
13
cy.visit('/smile')
.wait(2000)
.then(() => {
cy.log('Page.captureScreenshot')
// https://chromedevtools.github.io/devtools-protocol/
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
return Cypress.automation('remote:debugger:protocol', {
command: 'Page.captureScreenshot',
params: {
format: 'png',
},
})
})

The CDP documentation says the method returns an object with data property that is base64-encoded PNG image. We can grab this property and use cy.writeFile command to save the image.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cy.visit('/smile')
.wait(2000)
.then(() => {
cy.log('Page.captureScreenshot')
// https://chromedevtools.github.io/devtools-protocol/
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
return Cypress.automation('remote:debugger:protocol', {
command: 'Page.captureScreenshot',
params: {
format: 'png',
},
})
})
.its('data')
.then((base64) => {
cy.writeFile('test-smile.png', base64, 'base64')
})

The test runs and saves and image like this one:

Saved screenshot PNG image

If you want to capture only a portion of the page, grab the bounding box of an element and pass it as a parameter.

Limitation

As of Cypress v7, you can only execute a CDP automation command, not to subscribe to the browser events. If you need to subscribe, you would need to open your own remote interface connection, just like cypress-log-to-output does.

Track the issue #7942 for any updates to this feature in Cypress.

Yet, despite of this limitation right now, think what having an automation command in Cypress means - everyone that Puppeteer can do, Cypress can do too - it is the same Chrome Debugger Protocol connection after all! You want real click events? You want hover? You want a tab? No problem, see cypress-real-events for example.

Update 1: printing the current permission

Recently a user asked me why the browser permission stays unchanged even after Cypress.automation call. Here is the picture of the problem from the tweet:

The printed updated permission ... is the same as the old one

Do you see the problem? The cy.wrap(...) will correctly set the permission asynchronously. Meanwhile before and after we have cy.log(Notification.permission) calls that are passing their current argument by value. Imagine the permission value is prompt at the start. Then after the test runs through the commands and schedules them to run, the arguments to the cy.log calls in both cases will be prompt

1
2
3
4
5
6
# Cypress commands scheduled to execute
# with their parameters
CY.LOG "prompt"
CY.WRAP Promise from Cypress.automation
# Promise has started running, since they are eager
CY.LOG "prompt"

Thus to correctly print the updated permission, we need to call cy.log after the Cypress.automation has finished its execution and the Notification.permission has a new value.

1
2
3
4
5
6
7
8
9
10
11
cy.visit('/')
cy.log(Notification.permission)
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Browser.grantPermission',
...
})
).then(() => {
// the permission has been changed
cy.log(Notification.permission)
})

In fact, the above code has a race condition in the first cy.log vs Cypress.automation promise. The promise to grant the browser permission starts running as soon as it is created. Thus commands before cy.wrap could get the updated permission! I would rewrite the above code to be safer and to call Cypress.automation only after the first cy.log command.

1
2
3
4
5
6
7
8
9
10
11
12
cy.visit('/')
cy.log(Notification.permission)
.then(
Cypress.automation('remote:debugger:protocol', {
command: 'Browser.grantPermission',
...
})
)
).then(() => {
// the permission has been changed
cy.log(Notification.permission)
})

Since the cy.then command is chained after the cy.log command, the .then creates the Promise and waits for the returned promise from Cypress.automation to complete. This even eliminates the need for cy.wrap command.