Watch the meetup recording at
3:01am
Sometimes, it takes a few guesses, even with a hint
If I still cannot guess correctly
Want to subscribe to Wordle hint? $1 per month only
Cypress Dashboard with CI results
(show the run or go down)
The recording of the Cypress solving the Wordle
Ran on GitHub Actions, recorded to Cypress Dashboard
Cypress hint screenshot with just one revealed letter
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing
(show the site https://github.com/TomCools/playwright-wordle)
$ npm install -D cypress
it('adds 2 todos', () => {
cy.visit('http://localhost:3000')
cy.get('.new-todo')
.type('learn testing{enter}')
.type('be cool{enter}')
cy.get('.todo-list li')
.should('have.length', 2)
})
$ npx cypress open
New! 🎁
"Cypress Wordle" YouTube Playlist has 17 videos
https://www.youtube.com/playlist?list=PLP9o9QNnQuAaihgCPlXyzlj_P-1TTbj-O
describe('Wordle', () => {
it('loads', () => {
cy.visit('/index.html')
.its('localStorage.nyt-wordle-state')
.then(JSON.parse)
.its('solution')
.then((word) => {
cy.get('game-icon[icon=close]:visible').click()
word.split('').forEach((letter) => {
cy.window().trigger('keydown', { key: letter })
})
cy.window().trigger('keydown', { key: 'Enter' })
})
})
})
The game gives feedback for each letter
* Various strategies
** Edge cases with repeated letters
cy.visit('/index.html')
// the "window.wordList" variable is now available
// that will be our initial list of words
.its('wordList')
.then((wordList) => {
cy.get('game-icon[icon=close]:visible').click()
tryNextWord(wordList)
})
Start the test
import { enterWord, countUniqueLetters } from './utils'
function tryNextWord(wordList) {
const word = Cypress._.sample(wordList)
enterWord(word)
let count = 0
const seen = new Set()
cy.get(`game-row[letters=${word}]`)
.find('game-tile')
.should('have.length', word.length)
.each(($tile, k) => {
const letter = $tile.attr('letter')
if (seen.has(letter)) {
return
}
seen.add(letter)
const evaluation = $tile.attr('evaluation')
if (evaluation === 'absent') {
wordList = wordList.filter((w) => !w.includes(letter))
} else if (evaluation === 'present') {
wordList = wordList.filter((w) => w.includes(letter))
} else if (evaluation === 'correct') {
count += 1
wordList = wordList.filter((w) => w[k] === letter)
}
})
.then(() => {
// after we have entered the word and looked at the feedback
// we can decide if we solved it, or need to try the next word
if (count === countUniqueLetters(word)) {
cy.log('**SOLVED**')
} else {
tryNextWord(wordList)
}
})
}
The word list gets shorter and shorter with each guess
** Edge cases with repeated letters
// look up the word list in the JavaScript bundle
// served by the application
cy.intercept('GET', '**/main.*.js', (req) => {
req.continue((res) => {
// by inserting a variable assignment here
// we will get the reference to the list on the window object
// which is reachable from this test
res.body = res.body.replace('=["cigar', '=window.wordList=["cigar')
})
})
cy.visit('/index.html')
// the "window.wordList" variable is now available
// that will be our initial list of words
.its('wordList')
.then((wordList) => {
cy.get('game-icon[icon=close]:visible').click()
tryNextWord(wordList)
})
// look up the word list in the JavaScript bundle
// served by the application
cy.intercept('GET', '**/main.*.js', (req) => {
req.continue((res) => {
// by inserting a variable assignment here
// we will get the reference to the list on the window object
// which is reachable from this test
res.body = res.body.replace('=["cigar', '=window.wordList=["cigar')
})
})
cy.visit('/index.html')
// the "window.wordList" variable is now available
// that will be our initial list of words
.its('wordList')
.then((wordList) => {
cy.get('game-icon[icon=close]:visible').click()
tryNextWord(wordList)
})
// look up the word list in the JavaScript bundle
// served by the application
cy.intercept('GET', '**/main.*.js', (req) => {
req.continue((res) => {
// by inserting a variable assignment here
// we will get the reference to the list on the window object
// which is reachable from this test
res.body = res.body.replace('=["cigar', '=window.wordList=["cigar')
})
})
cy.visit('/index.html')
// the "window.wordList" variable is now available
// that will be our initial list of words
.its('wordList')
.then((wordList) => {
cy.get('game-icon[icon=close]:visible').click()
tryNextWord(wordList)
})
"Play Wordle From Any Date Using cy.clock" https://www.youtube.com/watch?v=ZmcOFr2UzZU
Cypress._.range(1, 31).forEach((day) => {
const date = `2022-01-${day}`
it(`plays the word from ${date}`, () => {
cy.clock(Date.UTC(2022, 0, day), ['Date'])
// play the game
})
})
tryNextWord(wordList).then((word) => {
cy.get('game-tile[letter]', silent).each(($gameTile) => {
cy.wrap($gameTile, silent)
.find('.tile', silent)
.invoke(silent, 'text', '')
})
const hint = pickHints(word, numberOfHints)
// the hint will be something like "__c_a"
// let's reveal the tiles containing the letters
hint.split('').forEach((letter, index) => {
if (letter !== maskLetter) {
cy.get(`game-row[letters=${word}]`)
.find('game-tile[letter]')
.eq(index)
.find('.tile')
.invoke('text', letter)
}
})
})
cy.get('#board-container')
.should('be.visible')
.screenshot('solved', { overwrite: true })
.then(() => {
const screenshot = `${Cypress.spec.name}/solved.png`
cy.task('sendHintEmail', { screenshot, hint })
})
Spec running in the browser calls "cy.task" to call Node code to actually send the email with the screenshot
const sgMail = require('@sendgrid/mail')
sgMail.setApiKey(process.env.SENDGRID_API_KEY)
async sendHintEmail({ screenshot, hint }) {
const screenshotPath = path.join(config.screenshotsFolder, screenshot)
const imageBase64 = fs.readFileSync(screenshotPath).toString('base64')
const msg = {
to: process.env.WORDLE_HINT_EMAIL,
from: process.env.SENDGRID_FROM,
subject: 'Wordle daily hint',
text: `Today's hint: ${hint}`,
html: `
<div>Today's hint: <pre>${hint}</pre></div>
<div><img src="data:image/png;base64,${imageBase64}" /></div>
<div>Solved by <a href="https://github.com/bahmutov/cypress-wordle">cypress-wordle</a></div>
`,
}
const response = await sgMail.send(msg)
}
// solves the Vue version of the Wordle game
// https://github.com/yyx990803/vue-wordle
import { Playing } from './pages'
import { solve } from '../utils/solver'
describe('Vue Wordle', { baseUrl: 'https://vue-wordle.netlify.app/' }, () => {
beforeEach(() => {
cy.fixture('wordlist.json').as('wordList')
})
it('finds the target word starting with another word', () => {
// the word to guess
const word = 'quick'
cy.visit(`/?${btoa(word)}`)
solve('start', Playing).should('equal', word)
})
})
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))
}
function tryNextWord(wordList, word, pageObject) {
if (!word) {
word = pickWordWithUniqueLetters(wordList)
}
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)
})
}
export const Playing = {
enterWord(word) {
word.split('').forEach((letter) => {
cy.window(silent).trigger('keyup', { key: letter })
})
cy.window(silent)
.trigger('keyup', { key: 'Enter' })
},
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 }
})
})
}
}
enterword(word) {
...
},
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')
return { k, letter, evaluation }
})
})
},
name: hint
# run the workflow every morning
# or when we trigger the workflow manually
on:
workflow_dispatch:
inputs:
email:
description: Email to send the hint to
type: string
default: ''
required: false
hints:
description: Number of letters to reveal
type: integer
default: 1
schedule:
# UTC time
- cron: '0 7 * * *'
jobs:
hint:
runs-on: ubuntu-20.04
steps:
- name: Checkout 🛎
uses: actions/checkout@v3
- name: Cypress run 🧪
# https://github.com/cypress-io/github-action
uses: cypress-io/github-action@v3
with:
spec: 'cypress/integration/email-hint.js'
env: 'hints=${{ github.event.inputs.hints }}'
env:
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
SENDGRID_FROM: ${{ secrets.SENDGRID_FROM }}
WORDLE_HINT_EMAIL: ${{ github.event.inputs.email || secrets.WORDLE_HINT_EMAIL }}
name: ci
on: [push]
jobs:
test:
uses: bahmutov/cypress-workflows/.github/workflows/parallel.yml@v1
with:
n: 5 # use 5 machines in parallel
secrets:
recordKey: ${{ secrets.CYPRESS_RECORD_KEY }}
Cypress Dashboard run load balances all spec files
Learn more: https://cypress.tips/ and https://docs.cypress.io/
Gleb Bahmutov
Sr Director of Engineering
Learn more: https://cypress.tips/ and https://docs.cypress.io/