Emulate Media In Cypress Tests

How to set the "prefers-color-scheme" value in a Cypress test.

The application

Let's say our application has different styles depending on the media and the user's current prefers-color-scheme setting. In my example, the HTML page is normally uses the black text on the white background. If the user has prefers-color-scheme: dark setting, the page uses cyan on black colors to show the text.

🎁 You can find the source code for this blog post in the repo bahmutov/cypress-emulate-media.

public/index.html
1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<title>cypress-emulate-media</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<p class="text">
Lorem ipsum dolor sit amet, ...
</p>
</body>
</html>
public/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
body {
font-size: 2rem;
margin: 2rem 4rem;
}

/* default color theme */
.text {
background-color: #fff;
color: #333;
}

@media (prefers-color-scheme: dark) {
body {
background: #000;
}
.text {
background: #000;
color: #d0d;
font-weight: bold;
}
}

@media (prefers-color-scheme: light) {
.text {
background: white;
color: #555;
}
}

The page shown in the browser that prefers the light color scheme

Emulate color scheme using the Chrome DevTools

The simplest way to see how the page looks with the prefers-color-scheme: dark is to open the browser DevTools (I am using Chrome), and run the command "Emulate CSS prefers-color-scheme ..."

Pick the DevTools command to emulate the dark color theme

The page switches to use its dark media CSS styles

The page shown in the browser that prefers the dark color scheme

How do we control the CSS media from a Cypress test?

DevTools automation

Cypress has a built-in DevTools automation channel as I described in the Cypress Automation blog post. If you know that it is possible to execute a command from the DevTools, then you can find the actual command using the Chrome Debugger Protocol site. I have found the Emulation commands that has what we need.

The Emulation DevTools protocol commands

Emulation.setEmulatedMedia command

Let's use it in our test

cypress/e2e/dark.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('prefers the dark color scheme', () => {
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'dark',
},
],
},
}),
)

cy.visit('public/index.html')
})

Success!

The Cypress web test running using the dark color preference

Tip: I use cy.wrap(...) around the Promise-returning Cypress.automation call to make all other Cypress commands like cy.visit wait until the Emulation.setEmulatedMedia command has finished. See my cy.wrap examples.

Confirm the applied CSS

Let's make sure the actual dark background color is used by the page. We can grab the DOM element and ask the window object to give us the computed CSS property.

cypress/e2e/dark.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
it('prefers the dark color scheme', () => {
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'dark',
},
],
},
}),
)

cy.visit('public/index.html')
cy.get('.text')
.then(($el) => window.getComputedStyle($el[0]).backgroundColor)
.then(cy.log)
.should('equal', 'rgb(0, 0, 0)') // black color!
})

The .then(($el) => window.getComputedStyle($el[0]).backgroundColor) code is a little unwieldy. Let's make a utility function to get us the computed style by name.

cypress/e2e/utils.js
1
2
3
4
// use Lodash _.camelCase to support both "backgroundColor" and "background-color"
const { camelCase } = Cypress._
export const getComputedProperty = (property) => ($el) =>
window.getComputedStyle($el[0])[camelCase(property)]
cypress/e2e/dark.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { getComputedProperty } from './utils'
it('prefers the dark color scheme', () => {
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'dark',
},
],
},
}),
)

cy.visit('public/index.html')
cy.get('.text')
.then(getComputedProperty('background-color'))
.then(cy.log)
.should('equal', 'rgb(0, 0, 0)') // black color!
})

Nice.

Confirm the light color scheme

Let's have a spec that explicitly sets the light color theme preference and verifies the colors.

cypress/e2e/light.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { getComputedProperty } from './utils'

it('uses the light color scheme', () => {
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'light',
},
],
},
}),
)

cy.visit('public/index.html')
cy.get('.text')
.then(getComputedProperty('background-color'))
.then(cy.log)
.should('equal', 'rgb(255, 255, 255)') // white!
cy.get('.text')
.then(getComputedProperty('color'))
.then(cy.log)
.should('equal', 'rgb(85, 85, 85)') // #333!
})

The Cypress web test running using the light color preference

Warning

Once you change the page media preferences, it stays that way. The Cypress Time-Traveling Debugger does NOT restore the media preferences when you hover over the command DOM snapshots. Thus if you have different tests in the same spec file, or switch the media preferences in the same test, it will not show the correct CSS styles when you inspect the commands.

cypress/e2e/light-and-dark.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { getComputedProperty } from './utils'

it('uses the light color scheme then the dark', () => {
cy.wrap(
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'light',
},
],
},
}),
)

cy.visit('public/index.html')
cy.get('.text')
.then(getComputedProperty('background-color'))
.then(cy.log)
.should('equal', 'rgb(255, 255, 255)') // white!
cy.get('.text')
.then(getComputedProperty('color'))
.then(cy.log)
.should('equal', 'rgb(85, 85, 85)') // #333!
.wait(1000) // for demo
.then(() =>
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: {
media: 'page',
features: [
{
name: 'prefers-color-scheme',
value: 'dark',
},
],
},
}),
)
cy.get('.text')
.then(getComputedProperty('background-color'))
.then(cy.log)
.should('equal', 'rgb(0, 0, 0)') // black color!
})

The test passes. But when I hover over the commands, the page CSS Media preference is not restored, and thus I see the last dark color them CSS.

The Cypress time-traveling debugger still uses the last media CSS

So just watch out for that.

Use cypress-cdp

You can simply Cypress automation commands and avoid the extra cy.wrap and cy.then(() => Cypress.automation(...)) code by using my plugin cypress-cdp. I will show how to use cy.CDP to emulate the color theme preference in my Cypress Plugins course.

Bonus 1: Emulate coarse pointer for touch devices

Imagine we are trying to design a responsive web app that works well on touch devices like phones and tablets. We might use the pointer CSS media query to vary the size of the elements for the user to press. Using a mouse? The checkbox can be normal. Using a finger? The resolution is coarse and the checkbox should be drawn larger.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Pointer is a fine pointer, such as a mouse */
@media (pointer: fine) {
input[type='checkbox'] {
width: 15px;
height: 15px;
border-width: 1px;
border-color: blue;
}
}
/* Something like a finger is a coarse input */
@media (pointer: coarse) {
input[type='checkbox'] {
width: 30px;
height: 30px;
border-width: 2px;
border-color: red;
}
}

If we open this page in the browser, we can see the difference by emulating the mobile device using the DevTools

Simulating mobile device changes the pointer media query from fine to coarse

By default, Cypress sets nothing for the pointer. Let's call Emulation.setTouchEmulationEnabled from our test.

cypress/e2e/pointer.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { getComputedProperty } from './utils'
// https://github.com/bahmutov/cypress-cdp
import 'cypress-cdp'

describe('Pointer', { viewportHeight: 200, viewportWidth: 200 }, () => {
it('is fine', () => {
cy.CDP('Emulation.setTouchEmulationEnabled', {
enabled: false,
})
cy.visit('public/pointer.html')
cy.get(':checkbox')
.then(getComputedProperty('border-color'))
.should('be.a', 'string')
.and('equal', 'rgb(0, 0, 255)')
})

it('is coarse', () => {
cy.CDP('Emulation.setTouchEmulationEnabled', {
enabled: true,
maxTouchPoints: 1,
})
cy.visit('public/pointer.html')
cy.get(':checkbox')
.then(getComputedProperty('border-color'))
.should('be.a', 'string')
.and('equal', 'rgb(255, 0, 0)')
})
})

Here is the difference in the page appearance

Each test sees a differently rendered button

Because Cypress does not reset the touch media emulation (or any other media emulations) before each test, we must do it ourselves. Let's add test tags and use a global beforeEach hook to set the media to avoid doing it for every test. Normally, we would place this beforeEach hook in the E2E support file. But for this demo, I will simply have it in the spec file itself.

cypress/e2e/pointer-reset.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { getComputedProperty } from './utils'
// https://github.com/bahmutov/cypress-cdp
import 'cypress-cdp'
// https://github.com/bahmutov/cy-grep
import registerCypressGrep from '@bahmutov/cy-grep/src/support'
registerCypressGrep()

// typically this hook could be placed in the e2e/support.js file
// and apply to _all_ tests across all specs
beforeEach(() => {
// get the tags from the current test config using
// the Cypress implementation details
const tags = cy.state('test')?._testConfig?.unverifiedTestConfig?.tags
// tags could be a single string or an array of strings
if (tags?.includes('@touch')) {
cy.CDP('Emulation.setTouchEmulationEnabled', {
enabled: true,
maxTouchPoints: 1,
})
} else {
cy.CDP('Emulation.setTouchEmulationEnabled', {
enabled: false,
})
}
})

describe('Pointer', { viewportHeight: 200, viewportWidth: 200 }, () => {
it('is fine', () => {
cy.visit('public/pointer.html')
cy.get(':checkbox')
.then(getComputedProperty('border-color'))
.should('be.a', 'string')
.and('equal', 'rgb(0, 0, 255)')
})

it('is coarse', { tags: '@touch' }, () => {
cy.visit('public/pointer.html')
cy.get(':checkbox')
.then(getComputedProperty('border-color'))
.should('be.a', 'string')
.and('equal', 'rgb(255, 0, 0)')
})
})

Here is a screenshot showing the beforeEach hook setting the proper mobile emulation for the second test.

Cypress sets the mobile emulation depending on the test tag "@touch"

Nice.