Playwright: Wordle, with no hands!

Using UI Automation to play Wordle.

Using Playwright to automatically play Wordle.

I recently decided to give UI Automation another try live on Twitch. I’ve used Selenium in the past but that always felt like a hassle. So this time, I wanted to try out a new competitor on the scene of UI Automation Libraries: Playwright. And what better way to get a feel for a library then by using it in a fun project. As this is the point in time where the online game Wordle is immensely popuplar… why not try and automate that?


wordle.jpg

Defining the problem

When trying to automate Wordle, there are a few sub-problems we need to solve. Each of which I’ll describe in more detail below.


  • Get a list of words… like a dictionary?
  • Select a word we want to try next;
  • Type that word in the UI;
  • Read the result from the webpage, hopefully win?!

The dictionary!

In order to determine words to type, we need to have a list of words present in the English language. Lucky for us living in the internet age, we can easily find such a file with a quick Google query: here is one such example.

Next we need to read the text out of that file. Using the Java Files API with the “words” file at the root of my project, this is what the code looks like. As we know Wordle only uses words with 5 characters, we filter the list to only include those words.


    Path path = Path.of("words");
    var lines = Files.lines(path))
    List<String> words = lines
            .filter(line -> line.length() == 5)
            .map(String::toLowerCase)
            .toList();

Selecting a word from the list

We have our dictionary to choose words from! Now to select a word which will help us get closer to the solution. As there are no “green” or “yellow” letters yet at the start of the game to place constraints on our choice, we can choose any 5 letter word from the dictionary.

Initially, I had chosen to just randomly take a word from the list.


    Collections.shuffle(words);
    // removes and returns the first entry in the collection.
    String chosenWord = words.remove(0); 

This proved to be very inefficiƫnt since it was often selecting words containing multiple instances of the same character.


toast.png

To combat this, we can change the selection mechanism to select words with the highest amount of different characters. This code example uses a Java record (WordAndDiffCharacters) to create a Pair structure to hold a word (a String) and the amount of different characters in that String.


    // Record definition to mimic a Pair structure.
    public record WordAndDiffCharacters(String word, long uniqueCharacters) {
    }
    
    // Method to determine a word to try
    private String determineWord(List<String> dictionary) {
    
        Collections.shuffle(dictionary); // shuffle
    
        final Optional<WordAndDiffCharacters> maxDiffCharacters = 
        dictionary.stream()
          .map(word -> new WordAndDiffCharacters(word, word.chars().distinct().count()))
          .max(Comparator.comparingLong(WordAndDiffCharacters::uniqueCharacters));
        
        return maxDiffCharacters.get().word();
    }

Using this code, we avoid using repeating characters for any guess we make. We’ll come back to this part of the code and make some improvements later. For now, let’s type our first word in Wordle using UI Automation.


Typing our first word!

So finally, it’s time to get a browser up and running and click some stuff! The “Getting Started” page of Playwright really does get you started quickly. Add some code, add a dependency in your Maven pom.xml and you are good to go.

The first time you run Playwright it downloads all the necessery webdrivers, so you don’t have to install anything extra. The example on the Getting Started page starts the browser in headless mode, so you don’t actually see it pop up. This can be changed by passing in some parameters when starting the browser.

The code below opens the browser and navigates to the Wordle webpage.


    try (Playwright playwright = Playwright.create()) {
        final Browser browser = playwright.chromium()
                .launch(new BrowserType.LaunchOptions().setHeadless(false));
        
        final Page page = browser.newContext().newPage();
        this.page.navigate("https://www.powerlanguage.co.uk/wordle/");
    }

With the page open, we need to find the right buttons to click in order to type our selected word. There are a couple of different ways we can select an element in Playwright, which are descibed in the documentation. These element selectors can be passed to methods on the page object.


    // A couple examples from the official documentation
    page.click("#nav-bar .contact-us-item"); //Select Element CSS Selector
    page.click("[data-test=login-button]"); // Select Element having a specific attribute with CSS

We have to do some digging into the HTML of the Wordle website in order to find a usefull selector. Using the Developer Tools and looking at the buttons we can see the following HTML:


    <!-- Other buttons ommitted -->
    <button data-key="q">q</button>
    <button data-key="w">w</button>
    <button data-key="e">e</button>
    <button data-key="r">r</button>

The structure is always the same, a “button” element with a “data-key” attribute which has the value of the corresponding character. To click the button, we can use that information to create a method which clicks a specific button using an element selector.


   public void selectLetter(char character){
        page.click("button[data-key=%s]".formatted(character));
   }

    public void enter() {
        // This uses a "Text Selector" to click the enter button.
        activePage.click("text=Enter");
    }

    public void typeWord(String word) {
        for (char c : word.toLowerCase().toCharArray()) {
            selectLetter(c);
        }
        enter();
        // more code ommitted
    }

Invoking the “typeWord” method with a parameter “tacos” (njam! šŸŒ®) will type out the word in our browser!

tacos.png

Note, this is the result of the word “tacos” on 15 jan 2021. The outcome will very likely be different if you try this any other day.

Not only did entering that word give us a slight feeling of hunger, it also gave us more information about the word we are looking for.

The “C” tells us that the character C is present, but in the wrong position. Even better is the “A”, which tells us that A is in the correct location! All the other characters are “gray”, meaning they are absent in the solution.


Reading the results from the screen

Time to read that information from the screen and get it into our Java code! We once again look at the HTML and try to find a way to select the results. Since we need to read multiple rows, this code is a bit more complex.


public LetterState determineLetterState() {
        Set<Character> absentLetters = new HashSet<>();
        List<MatchedLetters> presentLetters = new ArrayList<>();
        List<MatchedLetters> correctLetters = new ArrayList<>();

        // Find all elements with a "class=row" attribute within the <div id="board"> wrapper.
        List<ElementHandle> elementHandles = activePage.querySelectorAll("#board .row");
        for (ElementHandle elementHandle : elementHandles) {
            int position = 0;
            // For each of those rows, search all the elements with a "class=tile" attribute. 
            for (ElementHandle handle : elementHandle.querySelectorAll(".tile")) {
                // Read the text content of that element.
                // From inspecting the HTML, we know it only contains a single character.
                char letter = handle.textContent().charAt(0); 
                
                // Read the "data-state" attribute, which contains the result of that character.
                // Then add that character to the correct result bucket.
                switch (handle.getAttribute("data-state")) {
                    case "present":
                        presentLetters.add(new MatchedLetters(letter, position));
                        break;
                    case "absent":
                        absentLetters.add(letter);
                        break;
                    case "correct":
                        correctLetters.add(new MatchedLetters(letter, position));
                        break;
                }
                position += 1;
            }
        }
        // Wrap everything nicely into a single object.
        return new LetterState(correctLetters, presentLetters, absentLetters);
    }

Revisiting the word selection

Our implementation to select a word can be improved using the information we have collected. The results from previous words limit the pool of possibilities.

  • Green/Correct characters need to be on that exact position. So we can filter out any words where that position does not contain that character.
  • Gray/Absent characters should not be in the word at all. So any word containing any of those characters can be removed.
  • Yellow/Present characters exist in the word, but at a different position. So any word which doesn’t contain that letter at a different (non green!) position can be filtered out.

There are quite a few edgecase, specifically with words which contain the same letters multiple times. The code for filtering out impossible words is encapsulated in the LetterState class. You can find it on GitHub.


During my live Twitch stream I thought I could implement this logic without writing tests (it was a friday evening after all.). While I always write tests for my production work (often TDD)… it is clear that I should bring some of that testing energy to these kinds of “silly” endeavours.

Now that we collected the details of our previous attempts into the LetterState object, we pass that object as a parameter to our WordSelector.


public class WordSelector {
    private List<String> words;
    
    // Dictionary is passed in the constructor.
    public WordSelector(List<String> words) {
        this.words = words;
    }
    
    public String determineNextWord(LetterState letterState) {
        final List<String> validWords = words.stream()
                .filter(letterState::isPossibility) // Check if the word is still possbile
                .collect(Collectors.toList());
        
        final String selectedWord = optimize(validWords);
        
        validWords.remove(selectedWord);
        
        // Set the word list to only the remaining valid words.
        this.words = validWords;
        
        return selectedWord;
    }
    
    // This could be improved :-), ex. prioritizing the most frequent letters of the English language.
    private String optimize(List<String> validWords) {
        Collections.shuffle(validWords);
        
        final Optional<WordAndDiffCharacters> maxDiffCharacters = validWords.stream()
                .map(word -> new WordAndDiffCharacters(word, word.chars().distinct().count()))
                .max(Comparator.comparingLong(WordAndDiffCharacters::uniqueCharacters));
        return maxDiffCharacters.get().word();
    }
}

With all this combined, we have our solution. The full example can be found on Github, but here is a video. I recorded it with Playwright straight from the code!


What I like about PlayWright

I’ve used Selenium in the past and compared to that, Playwright just brings me more joy. I’ll explain some of the things I like about Playwright by contrasting it with my experience with Selenium.


Note: It has been a while since I’ve used Selenium, so it could be that a newer version takes away some of the concerns I’m going to list here.

A while ago, I was teaching a workshop on Automated UI Testing using Selenium at a university college (guest lector). We spent well over an hour helping everyone get their webdrivers and PATH configured correctly before we could even start. Playwright downloads the binaries on first startup. Just run the code and go.

When working with dynamic websites (Angular, React, etc.) with plenty of animations, your testing library needs to wait for elements to be visible/present/clickable. Playwright awaits by default, polling the DOM until your requested action can execute or a timeout is reached. With Selenium, this is not the default. Implicit waits can be configured but many sources (StackOverflow, Blogs, etc.) nearly always show a preference for the noisy explicit wait style. Defaults matter.

When it comes to more advanced features, I’ve always found Selenium to be extremly verbose or just plain lacking. For example, compare the following code for invoking some Javascript.


// Selenium
String netData = ((JavascriptExecutor)driver).executeScript("document.location.href").toString();

// Playwright
String href = (String) page.evaluate("document.location.href");

Another more advanced feature is Playwright’s ability to intercept Network Requests/Responses..

Instead of reading a list of words (a dictionary worth) from a file as described earlier, we can now get a list of words another way. We can leverage the Network API to get the list of recognized words straight from the Wordle Javascript file. This API allows access to everything you’d see in the Network tab of your browser’s Developer Tools.


    // Instead of reading a bunch of words the game doesn't understand, we take all the words from the JS file ;)
    public class ExtractFromScriptDictionary implements Dictionary {
        private final static Pattern FIVE_TOKENS_PATTERN = Pattern.compile("\"[a-z]{5}\"");
        private final Page page;
    
        public ExtractFromScriptDictionary(Page page) {
            this.page = page;
        }
    
        @Override
        public List<String> wordList() {
            // Wait for response from a URL containing "main" and ending with "js".
            final Response response = page.waitForResponse(r -> r.url().contains("main") && r.url().endsWith("js"), () -> {
                // A Runnable which is always run, and is meant to trigger the response.
                this.page.navigate("https://www.powerlanguage.co.uk/wordle/");
            });
    
            return extractWordList(response);
        }
    
        private List<String> extractWordList(Response r) {
            final String jsonFile = r.text();
            final Matcher matcher = FIVE_TOKENS_PATTERN.matcher(jsonFile);
            return matcher.results()
                    .map(MatchResult::group)
                    .map(s -> s.replace("\"", "")) // remove extra quotes
                    .distinct() // remove the duplicates
                    .collect(Collectors.toList());
        }
    }

Using this code to determine our dictonary of words we:

  • Have a much smaller list of words to choose from;
  • Avoid issues where “the word does not exist” according to Wordle;
  • Get a pretty neat example to use the network-data;

For other advanced features, like invoking Javascript functions or listening to events, be sure to check out the complete documentation.

Conclusion

From the low-friction start all the way through the support for complex operations, Playwright is quickly becoming my UI Automation library of choice. There is always a certain level of “finicky-ness” when it comes to automating UI but the Java Playwright API contains all the features to support you in your UI Automation journey. If Java is not your fancy, Playwright is also available for Node, Python and .NET.


java.png

There are still more things I want to explore, like the Debugging Tools or some other features like emulation to run the same tests on different device sizes. But that will be for another day.

Until then…


Lots of 💖
Tom

Code available on Github.

While coding on Twitch, one of the people in chat decided to try the challenge themselves. DasBrain’s code can be found on their Github. It’s definitely worth a look if you want to see a different way to approach the same problem.


comments powered by Disqus