Catching early DOM events in Playwright
Yesterday, I wrote about how to test custom events in Playwright.
In building out the tests for Kelp’s web components, I ran into an interesting challenge on the ready events I emit when the component is first instantiated.
Today, I wanted to explain the problem, and how I fixed it. Let’s dig in!
The challenge
Kelp uses an HTML Web Component approach for most of it’s components, but there are a few exceptions where that doesn’t make sense.
For example, the <kelp-toc> component generates a table of contents from the headings on the page. There’s no meaningful fallback state for that, so it starts out as an empty HTML element.
<kelp-toc></kelp-toc>Typically in Playwright, you…
- Use one of the
page.locator()methods to get your element. - Wait for it to be found/loaded.
- Wait for your event to run.
const component = page.locator('kelp-toc');
const readyEvent = await component.evaluate((element) => {
return new Promise((resolve) => {
return element.addEventListener('kelp-toc-ready', () => {
return resolve(true);
});
})
});
await expect(readyEvent).toBeTruthy();
That works great for HTML Web Components, but fails on step 2 for JS-rendered ones like <kelp-toc>.
Timing issues
Step 2—wait for it to be found/loaded—doesn’t run until the element has a visible bounding rectangle.
Because custom elements are treated like a <span>, and <kelp-toc> is empty by default, it has a height and width of 0. To Playwright, it’s not loaded yet.
By the time Playwright recognizes it—after the content has been rendered—the event has already fired, and the event listener/promise never resolves. The test fails.
There’s a simple fix!
One of the things I love about Playwright is that it tests real HTML files.
I’m using it for component testing rather than end-to-end, so each test spec has a corresponding HTML file with the markup hard-coded into it. I like this a lot better than having to dynamically inject HTML with JS for testing!
|-- /tests
| |-- /toc
| |-- index.html
| |-- toc.spec.js
In the test HTML, I add an event listener for the ready event before the web component JavaScript is loaded.
When the event fires, I log ready to the console. This is actually an import part of the test!
<script>
// Ensures we can test the ready event
document.addEventListener('kelp-toc-ready', (event) => {
console.log('ready');
});
</script>
<script type="module" src="../../js/components/kelp-toc.js"></script>Over in my test file, I…
- Define an
isReadyvariable asfalse. - Use the Playwright
page.on('console')method to listen forconsoleevents. If theconsoletext isready, setisReadytotrue. - Navigate to the test
index.htmlfile - Check if
isReadyistrue.
test('component instantiates', async ({ page }) => {
let isReady = false;
page.on('console', msg => {
if (msg.text() !== 'ready') return;
isReady = true;
});
await page.goto('/tests/toc');
expect(isReady).toEqual(true);
});
Is it silly? Yes! Does it work? Also yes!
This wouldn’t be a great solution in an end-to-end test, where you’d be logging this stuff in the actual app. But for unit/component testing, it works great!