testingplaywrighte2e-testingplaywright-typescript

How to wait for Angular lazy-loaded chunks before interacting with elements in Playwright?


I'm testing an Angular web application with Playwright where buttons are loaded via lazy-loaded chunks (chunk-XXXXXXXX.js). When Playwright tries to click a button too quickly, the click doesn't work because the JavaScript logic for that button hasn't loaded yet.

The button appears in the DOM, but the click handler isn't attached because the corresponding chunk file hasn't finished loading and executing.

Solutions like page.waitForLoadState('networkidle') and page.waitForTimeout(5000) work but are discouraged in playwright project.

What's the best way to wait for all Angular chunks to be loaded and executed before proceeding with test interactions?

Current failing code:

await page.goto('/my-page');
await page.getByTestId('dialog-button').click(); // Fails - chunk not loaded
await expect(page.getByRole('dialog')).toBeVisible();

I need a reliable way to ensure the button's functionality is ready without using networkidle or hard timeouts.


Solution

  • As I mentioned in a comment, when your application shows buttons that do nothing when clicked, this is a bug. When the tests fail, they're working as intended by loudly telling you your application is broken for users.

    The true solution is to fix your application. Use a loading skeleton or disable those buttons until they're genuinely hydrated and actionable, then your tests will all "just work".


    If, for some reason, you can't do this, a workaround without a sleep or network idle is possible: expect().toPass() polls the click until it works. Here's a minimal example where buttons do nothing for a few seconds, then eventually hydrate.

    import {expect, test} from "@playwright/test";
    
    const html = `<!DOCTYPE html><html lang="en"><body>
    <button data-testid="dialog-button">Open Dialog</button>
    <div id="dialog-container"></div>
    <script>
    setTimeout(() => {
      document
        .querySelector("[data-testid='dialog-button']")
        .addEventListener("click", () => {
          document.querySelector("#dialog-container").innerHTML = \`
            <dialog open>
              <p>Greetings, one and all!</p>
              <form method="dialog">
                <button>OK</button>
              </form>
            </dialog>\`;
        });
    }, 3000); // simulate 3 seconds for Angular to load
    </script></body></html>`;
    
    test("eventually works once handlers load", async ({page}) => {
      await page.setContent(html); // For demonstration
      await expect(async () => {
        await page.getByTestId("dialog-button").click();
        await expect(page.getByRole("dialog")).toBeVisible();
      }).toPass();
    });
    

    Yes, this is vanilla JS, but I don't think it really matters which framework you're using--the solution is the same from Playwright's perspective.

    You'd only need this pattern on the first click after the page loads, but since the page is probably reloaded per test to keep state pure (I hope...), then this ugliness will accrue quickly. You could hide it in a generic beforeEach() that ensures chunks are loaded by polling a button common to all pages, but this is still not normal to have to resort to.

    So I'd push hard to avoid this antipattern. It's ugly and can hide genuine, user-impacting errors (please refer back to top paragraph).

    Another approach is to use waitForResponse for the script(s) that hydrate the buttons. The thing is, chunk scripts usually have randomly generated hashes, so this seems like it'll be somewhat cumbersome to code, and is still smelly for the same reasons as polling.


    Here's the sample site under test for reference:

    <button data-testid="dialog-button">Open Dialog</button>
    <div id="dialog-container"></div>
    <script>
    setTimeout(() => {
      document
        .querySelector("[data-testid='dialog-button']")
        .addEventListener("click", () => {
          document.querySelector("#dialog-container").innerHTML = `
            <dialog open>
              <p>Greetings, one and all!</p>
              <form method="dialog">
                <button>OK</button>
              </form>
            </dialog>`;
        });
    }, 3000); // simulate 3 seconds for Angular to load
    </script>