Handling Modals, Alerts & Popups in Playwright
Modals, dialogs, and popups are tiny UI pieces that cause big headaches in E2E automation. They introduce timing, focus, and context-switching problems. Today we’ll walk through robust Playwright patterns to handle:
- native browser dialogs (`alert`, `confirm`, `prompt`)
- DOM-based modals (Bootstrap, React, custom overlays)
- popups opened with `window.open`
- interacting with content inside iframes shown in modals.
1. Native browser dialogs (alert / confirm / prompt)
Native dialogs are triggered by `window.alert()`, `window.confirm()`, and `window.prompt()`. Playwright exposes them via its `Dialog` API.
Why they fail: if you don’t accept/dismiss the dialog, the page script can hang and tests time out.
Pattern (recommended):
page.once('dialog', async dialog => {
console.log('Dialog type:', dialog.type()); // 'alert' | 'confirm' | 'prompt'
console.log('Message:', dialog.message());
await dialog.accept(); // or dialog.dismiss()
});
await page.click('text=Delete');
Prompt example:
page.once('dialog', d => d.accept('yes, please'));
await page.click('text=Enter promo code');
Notes:
- Use page.once('dialog') to avoid leaking handlers across tests.
- page.waitForEvent('dialog') is also handy when you need the dialog object returned.
2. DOM modals (React/Bootstrap/custom overlays)
Many apps render "modals" as part of the DOM instead of native dialogs. These require waiting for visibility, interacting with elements, and asserting the modal closed.
Common issues:
- Modal overlay blocks clicks until animation finishes
- Focus may be on a different element
- Multiple overlays stacking (z-index)
Pattern:
const modal = page.getByRole('dialog', { name: /confirm/i }); // accessibility-first
await modal.waitFor({ state: 'visible' });
await modal.getByRole('button', { name: 'Confirm' }).click();
await expect(modal).toBeHidden();
Fallbacks & tricks:
- If animations cause flakiness, wait for animationend or use await modal.waitFor({ state:'visible' }) then expect(modal).toHaveCSS('opacity', '1') if needed.
- Prefer role selectors (getByRole) for accessibility and resilience.
- Use keyboard fallback: await page.keyboard.press('Escape') to close modal when appropriate.
3. Popups (window.open
)
When the app opens a new browser window/tab, you must capture that popup and interact with it.
Pattern:
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.click('text=Open Report')
]);
await popup.waitForLoadState('domcontentloaded');
await popup.getByText('Download').click();
Tips:
- Use waitForEvent('popup') in the same Promise.all as the click for reliability.
- popup behaves like a Page — use all page methods on it.
4. Iframes inside modals
If a modal contains an iframe (payment form, embedded widget), you need to switch context.
Pattern with frameLocator:
const frame = page.frameLocator('#modal iframe');
await frame.locator('button#submit').click();
Or by frame name:
const frame = page.frame({ name: 'payment' });
await frame.click('button#pay-now');
Tip: frameLocator
avoids brittle waits and is the modern recommended way.
5. Practical test combining a few flows
test('handles modal with iframe and popup', async ({ page }) => {
// mock API so modal content is predictable
await page.route('**/api/get-modal', route => route.fulfill({ status: 200, body: JSON.stringify({ showModal: true }) }));
// open page that will show modal
await page.goto('https://example.com/dashboard');
// wait for DOM modal
const modal = page.getByRole('dialog', { name: /payment/i });
await modal.waitFor({ state: 'visible' });
// interact with iframe inside modal
const frame = modal.frameLocator('iframe[name="pay"]');
await frame.locator('input#card-number').fill('4111 1111 1111 1111');
await frame.locator('button#pay').click();
// popup for receipt
const [receipt] = await Promise.all([
page.waitForEvent('popup'),
frame.locator('a#open-receipt').click()
]);
await receipt.waitForLoadState();
await expect(receipt).toHaveTitle(/Receipt/);
});
6. Pro tips & “surgery” for flaky modal tests
- Use accessibility selectors (getByRole) — they’re stable and intent-driven.
- Avoid force: true unless absolutely required — it hides real UI problems.
- Close handlers: always remove dialog listeners after test or use page.once.
- Trace: use Playwright tracing to capture failing runs and examine screenshot/DOM.
- Fallbacks: if modal sometimes appears off-screen, use locator.scrollIntoViewIfNeeded().
- Timeout tuning: increase timeout only when necessary — prefer waiting on a meaningful condition (selector state, network idle).
7. Common gotchas
- Third-party widget modals (Stripe, PayPal) often live in cross-origin iframes — use test card numbers and test keys or mocks.
- Recaptcha and bot detection: do not attempt to bypass; mock or stub the upstream verification in test environments.
- Stale element errors: re-query locators after actions instead of reusing detached handles.
Final thoughts
Handling modals reliably short-circuits a large portion of flaky tests. With the right patterns — dialog handlers for native alerts, role-based selectors for DOM modals, waitForEvent('popup')
for new windows, and frameLocator
for iframe content — your tests will be far more deterministic and CI-friendly.
Follow @thebughacker and tag #30DaysOfPlaywrightAutomation if you used these patterns!