Solving JS equality game with Cypress test runner

Using Cypress end-to-end test runner to automate solving JavaScript equality game.

Today a game has appeared that shows how tricky JavaScript == operator can be. You can check out the game yourself at https://slikts.github.io/js-equality-game/. You have to fill the field by clicking every cell where the row and column labels are equal when using == operator. For example true == 1 in JavaScript, so you should click that cell. Whenever you click on a cell, the symmetric cell is also checked, because true == 1 implies 1 == true (at least JavaScript got this part right).

JS equality game

I got to admit, JavaScript equality as Minesweeper has been my jok for a long time, for example in this "Functional JavaScript" workshop from 2016 I start with these two slides

Minesweeper and equality

I don't even think that equality is that bad - the inequality < operator table looks scarier!

JavaScript < operator

Anyway, how do we solve the js-equality game? We have to click a lot of cells, set the flags, then click on "Show Results" and hope we got all the true statements selected. Doing this by hand does seem tiresome. If only we could automate this ...

Enter Cypress - the end-to-end testing tool I am working on with bunch of awesome people. Let's use Cypress to drive the game. I will use Cypress because it is super powerful whenever something needs to be tested in a browser, and also because we can see it in action. Just npm i -D cypress and we are ready to rumble.

You can find the complete project in https://github.com/bahmutov/js-equality-game repo.

1
2
3
git clone [email protected]:bahmutov/js-equality-game.git
cd js-equality-game
npm install

Once we open Cypress for the first time using npx cypress open, it scaffolds the folder cypress. We can start a test file cypress/integration/spec.js and the first thing we should do - is just visit the game's page.

cypress/integration/spec.js
1
2
3
4
/// <reference types="cypress" />
it('js-equality', () => {
cy.visit('https://slikts.github.io/js-equality-game')
})

Open Cypress with npx cypress open ... and see a cross-origin request error.

Cross-origin request

No biggie, this is just a font request to fonts.gstatic.com, we can solve this error by disabling chromeWebSecurity setting in cypress.json

cypress.json
1
2
3
4
5
{
"viewportWidth": 1400,
"viewportHeight": 1200,
"chromeWebSecurity": false
}

Great, no more errors. Let us start coding. First we need to grab values to compare. We can grab these values from the header cells of the table. Just use cy.get('thead th') and iterate over the elements, pushing values into a list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <reference types="cypress" />
it('js-equality', () => {
cy.visit('https://slikts.github.io/js-equality-game')
// get all values to compare
let values = []
cy.get('thead th')
.each((el$) => {
if (!el$.text()) {
return
}
// only '{}' gives back undefined, but should be an empty object {}
values.push(eval(el$.text()))
})
.then(() => {
console.log('%d value(s) to compare', values.length)
console.log(values)
assert(values.length > 0, 'expected some values to compare')
})
})

Here is where this solution falls short: one of the labels has an empty object {} which evaluates to undefined in the expression eval('{}'), hmm. You can see the evaluated values in the DevTools console; all values are correct except for this one.

Evaluated thead cells

We could "fix" this using an if / else condition, but I left this shortcoming in. This will actually test how the game handles missed values, we expect NOT to get 100% right.

Whenever Cypress grabs DOM elements, it saves DOM snapshots with real elements. Thus we can click on any command in the Cypress command log and see real elements printed in the DevTools console. Hover or click on them and the browser highlights the actual element in the application.

Hovering over DOM snapshot element

Great, we got (almost) all values to compare, now let's actually play. We need to iterate over all cells in the game grid, and for each compute row label value == column label value. If the expression is true, we should click that cell. Here is how I wrote it:

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// get values
.then(() => {
cy.get('tbody td').each((cell$, k) => {
const row = Math.floor(k / values.length)
const col = k % values.length
if (col <= row) {
// only interested in filling the upper right triangle of the field
return
}

const x = values[row]
const y = values[col]
console.log(x, '==', y, '?', x == y)

if (x == y) {
// click on the cell
}
})
})

The command cy.get('tbody td') returns all cells in the table, and we can iterate over them using .each command. From the index k that goes from 0 to 440 we can get the row and column coordinates of the cell, and grab the values to compare

1
2
3
4
5
6
7
8
9
10
const row = Math.floor(k / values.length)
const col = k % values.length
if (col <= row) {
// only interested in filling the upper right triangle of the field
return
}

const x = values[row]
const y = values[col]
console.log(x, '==', y, '?', x == y)

Iterating over the cells

If value x is equal to value y after casting we should click on that cell. Since we get a real element, we can just wrap it with Cypress and call click.

1
2
3
4
5
console.log(x, '==', y, '?', x == y)
if (x == y) {
// click on the cell
cy.wrap(cell$).click()
}

Great, but here is where we should be cautious and remember that our test runner has NO idea what the web application is doing under the hood. If we click cells really really quickly without checking that the app has registered clicks, we can get a flaky test, where some clicks are not registered. So every time we click, we should make sure the app has processed our action. The simplest observable UI change on each click is the "flag" counter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let clicks = 0
cy.get('tbody td').each((cell$, k) => {
const row = Math.floor(k / values.length)
const col = k % values.length
if (col <= row) {
// only interested in filling the upper right triangle of the field
return
}

const x = values[row]
const y = values[col]
console.log(x, '==', y, '?', x == y)

if (x == y) {
// click on the cell
cy.wrap(cell$).click()
// and make sure the field has registered our click
clicks += 1
cy.contains('.Results-flags .Score', clicks)
}
})

Cypress clicks on every cell with x == y passing, and then waits long enough for the flag counter to change. All Cypress actions are enqueued automatically, thus cy.contains(...) waits until cy.wrap(...).click() finishes successfully.

Now it is time to finish and reveal our score.

1
2
3
4
5
6
7
8
9
.then(() => {
// click on the grid cells
})
.then(() => {
cy.log('time to find out the truth ...')
cy.contains('Show Results').click()
// because we cannot `eval({})` correctly, we miss 9% of the grade 🙃
cy.contains('.Results-face', '91% correct')
})

We get the right answer! When we click on cy.contains('Show Results').click() command in the reporter on the left, it shows that there are "before" and "after" DOM snapshots. Cypress notices when a command modifies the DOM and stores two snapshots in these cases, allowing the user to inspect how the command changes the user interface.

Before clicking "Show Results"

After clicking "Show Results"

It is a good time to note that Cypress includes full video recording by default, so I can show the script in action using npx cypress run.

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
48
49
50
51
52
53
54
$ npx cypress run

====================================================================================================

(Run Starting)

┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 3.1.0 │
│ Browser: Electron 59 (headless) │
│ Specs: 1 found (spec.js) │
└────────────────────────────────────────────────────────────────────────────────────────────────┘


────────────────────────────────────────────────────────────────────────────────────────────────────

Running: spec.js... (1 of 1)


✓ js-equality (4711ms)

1 passing (6s)


(Results)

┌─────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: true │
│ Duration: 6 seconds │
│ Spec Ran: spec.js │
└─────────────────────────┘


(Video)

- Started processing: Compressing to 1 CRF
- Finished processing: /private/tmp/test-mine/cypress/videos/spec.js.mp4 (0 seconds)


====================================================================================================

(Run Finished)


Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ spec.js 00:06 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
All specs passed! 00:06 1 1 - - -

Here is the video file - it was a quick game 😄

JS equality game - tested

More info