Wordle Page Objects

Solving different Wordle game implementations via Page Objects

The game Wordle is pretty popular, and I have been solving / playing it using Cypress for a while. You can find my code in the repo bahmutov/cypress-wordle and multiple short videos showing my test implementations in this YouTube playlist.

Cypress solved Wordle game

Recently, other Wordle game implementations have appeared. For example A Greener Wordle let's you play using only the words related to the climate crisis. Another version lets you encode the target word via URL query parameter so your friends can guess the word you send them. For example, we can send the URL with the word "quick" encoded using base64 algorithm and appended to the url as cXVpY2s=. The test correctly calculates the answer starting with the word "start" in four steps.

Cypress solved the custom Wordle implementation

How can the same code solve the two different implementations of the game? If we inspect the page structure, the two games are implemented very differently. The original game is implemented using WebComponents with shadow DOM elements everywhere.

The first Wordle implementation uses WebComponents

The Vue version uses a single DOM with its own classes and attributes used to show each letter's status.

The second Wordle implementation uses Vue framework

The algorithm to solve the game is independent of the page. To solve the game we need to pick a word from the word list, enter the letters into the game, collect the game's feedback (which letter is at the correct position, which letter is present, and which letter is absent). We use the feedback to prune the word list, and pick a word again.

The page objects

We can abstract entering the word and collect the letter feedback by using a page object. It will be a simple JavaScript object with methods to call the Cypress commands. A page object is specific to the implementation - thus we will have one object for the first Wordle game implementation, and another page object for the second implementation.

Here is the first page object.

cypress/integration/utils/pages.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
export const Playing = {
enterWord(word) {
word.split('').forEach((letter) => {
cy.window(silent).trigger('keydown', { key: letter, log: false })
})
cy.window(silent)
.trigger('keydown', { key: 'Enter', log: false })
// let the letter animation finish
.wait(2000, silent)
},

/**
* Looks at the entered word row and collects the status of each letter
*/
getLetters(word) {
return cy
.get(`game-row[letters=${word}]`)
.find('game-tile', silent)
.should('have.length', word.length)
.then(($tiles) => {
return $tiles.toArray().map((tile, k) => {
const letter = tile.getAttribute('letter')
const evaluation = tile.getAttribute('evaluation')
console.log('%d: letter %s is %s', k, letter, evaluation)
return { k, letter, evaluation }
})
})
},
}

Here is the second page object - notice how it uses "keyup" instead of "keydown" events to input the characters.

cypress/integration/vue-wordle/pages.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
48
49
const silent = { log: false }

// interact with the VueWordle via custom page object
export const Playing = {
enterWord(word) {
word.split('').forEach((letter) => {
cy.window(silent).trigger('keyup', { key: letter, log: false })
})
cy.window(silent)
.trigger('keyup', { key: 'Enter', log: false })
// let the letter animation finish
.wait(2000, silent)
},

/**
* Looks at the entered word row and collects the status of each letter
*/
getLetters(word) {
return cy
.get('#board .row .tile.filled.revealed .back')
.should('have.length.gte', word.length)
.then(($tiles) => {
// only take the last 5 letters
return $tiles
.toArray()
.slice(-5)
.map((tile, k) => {
const letter = tile.innerText.toLowerCase()
const evaluation = tile.classList.contains('correct')
? 'correct'
: tile.classList.contains('present')
? 'present'
: 'absent'
console.log('%d: letter %s is %s', k, letter, evaluation)
return { k, letter, evaluation }
})
})
},

/** Checks if the Wordle was solved */
solved(greeting) {
// contains the given greeting (like "Genius") ig any
;(greeting ? cy.contains('.message', greeting) : cy.get('.message'))
.should('be.visible')
// contain the solved tiles minimap
.find('pre')
.should('be.visible')
},
}

The page object can have other methods to do additional actions on the page, like Playing.solved() to close the popup at the end.

You can watch a short video where I make a page object below.

Solver

Now that we have a page object to perform actual operations on the page, let's use it from a function that solves the Wordle. The solver function is almost pure - meaning it does not touch the page and does not have to deal with the particular implementation. If it needs to enter the word or interact with the page, it uses the page object passed as a parameter. Here is a short version of the solver without details

cypress/integration/utils/solver.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
export function solve(startWord, pageObject) {
expect(pageObject, 'page object')
.to.be.an('object')
.and.to.respondTo('enterWord')
.and.to.respondTo('getLetters')

return cy
.get('@wordList')
.then((wordList) => tryNextWord(wordList, startWord, pageObject))
}

/**
* Takes the feedback from the game about each letter,
* and trims the word list to remove words that don't match.
*/
function updateWordList(wordList, word, letters) {
...
}

/**
* Takes the word list and the word and uses the page object
* to enter the word, get the feedback, and proceed to the next word.
*/
function tryNextWord(wordList, word, pageObject) {
// we should be seeing the list shrink with each iteration
cy.log(`Word list has ${wordList.length} words`)
if (!word) {
word = pickWordWithUniqueLetters(wordList)
}
cy.log(`**${word}**`)
pageObject.enterWord(word)

return pageObject.getLetters(word).then((letters) => {
wordList = updateWordList(wordList, word, letters)
if (wordList === word) {
// we solved it!
return word
}
return tryNextWord(wordList, null, pageObject)
})
}

Notice how the tryNextWord uses pageObject.enterWord and pageObject.getLetters method calls to access the page?

Specs

The individual specs are where the solver and the page objects are put together. To solve the first Wordle implementation, we pick the first page object to pass to the solver function.

cypress/integration/solve.js
1
2
3
4
5
6
7
8
9
10
11
// use page objects to close the modals, solve the puzzle, etc
import { Start, Playing, Solved } from './utils/pages'
import { solve } from './utils/solver'

it('solves the game', function () {
cy.fixture('wordlist.json').as('wordList')
cy.visit('/')
Start.close()
solve('grasp', Playing).should('equal', 'sugar')
Solved.close()
})

Tip: I like having different page objects for different stages of the page. In this case, there is a page object called Start to close with the initial popup.

The spec to solve the Vue version of the game imports its own page objects but calls the same solve function.

cypress/integration/vue-wordle/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
import { Playing } from './pages'
import { solve } from '../utils/solver'

describe('Vue Wordle', { baseUrl: 'https://vue-wordle.netlify.app/' }, () => {
it('solves the game', function () {
cy.fixture('wordlist.json').as('wordList')
// the word to guess
const word = 'quick'
cy.visit(`/?${btoa(word)}`)
solve('start', Playing).should('equal', word)
})
})

Works beautifully. If you prefer to learn how to use page objects, solvers, and specs, watch the video below.