Monaco and Playwright don't "play right" together
So I’ve been giving a lot back to the open source community lately.
Among the projects I’m working on there is Zod Playground which I invite you to play with.
I was writing some quick e2e tests with Playwright, but it gave me a ten-minutes headache due to how code editors are implemented on web pages.
If you didn’t know, libs like Monaco, ProseMirror and CodeMirror are not real HTML Textareas, but Divs that gets changed as keystrokes are recorded.
This makes difficult the retrieval and insertion of characters for an e2e testing tool.
After some digging, I found 2 ways:
- Interact with the global Monaco object
- Use the right combination of selectors, properties and functions
The Monaco object
As you’ve probably heard over and over, your tests should resemble the way your software is used. Manually calling functions on a global object is definitely not something your users will do when they want to write some text within Monaco.
Still, when you have no choice or you’re only trying to read the editor’s content, it’s acceptable.
test("Interacts with the Monaco object", async ({ page }) => {
await page.goto("https://microsoft.github.io/monaco-editor/playground.html");
const firstEditorValue = await page.evaluate(() =>
window.monaco.editor.getEditors()[0].getValue().replace(/\s+/g, "")
);
expect(firstEditorValue).toEqual(
'constvalue=/*setfrom`myEditor.getModel()`:*/`functionhello(){alert(\'Helloworld!\');}`;//Hoveroneachpropertytoseeitsdocs!constmyEditor=monaco.editor.create(document.getElementById("container"),{value,language:"javascript",automaticLayout:true,});'
);
});
I’m using the official Monaco playground from Microsoft. There are three editors on the left, and I chose to focus on and assess the content from the first one. Notice the regex I’ve used to strip the whitespaces, which are a nasty cause of inconsistent results, sadly.
The better way
What if I’d like to write some text instead? You could use the setValue function, but that would be terrible.
This is better:
test("Write inside Monac", async ({ page }) => {
await page.goto("https://microsoft.github.io/monaco-editor/playground.html");
const editor = page.getByRole("code").nth(0);
await editor.click();
await page.keyboard.press("ControlOrMeta+KeyA");
await page.keyboard.type("the new text");
});
So each Monaco editor will have the “code” role that we can use to target the elements and truly interact with them like a user would do.
And if we want to use the same approach for reading content as well, we can do this:
test("Read from Monaco", async ({ page }) => {
await page.goto("https://microsoft.github.io/monaco-editor/playground.html");
const editor = page.getByRole("code").nth(0);
const firstEditorValue = (await editor.textContent())?.replace(/\s+/g, "");
expect(firstEditorValue).toEqual(
'1234567891011constvalue=/*setfrom`myEditor.getModel()`:*/`functionhello(){alert(\'Helloworld!\');}`;//Hoveroneachpropertytoseeitsdocs!constmyEditor=monaco.editor.create(document.getElementById("container"),{value,language:"javascript",automaticLayout:true,});'
);
});
The only weird thing is that we also get the indexes of the lines at the beginning of the textContent value; I found it to be an acceptable trade-off.
Choose your poison ☠️