Dialog boxes, confirmation prompts, and popup windows are unavoidable in modern web applications. When you're automating tests with Playwright, these UI elements can derail your test suite if you don't know how to intercept and control them. This guide covers handling dialogs and popups: what developers should know, so you can write reliable tests that manage alerts, confirms, and new tabs without flakiness or manual intervention.
Understanding browser dialogs in Playwright
Browser dialogs—alerts, confirms, and prompts—are synchronous blocking calls that pause JavaScript execution until the user responds. In a real browser, a user clicks OK or Cancel. In automated tests, we need to programmatically accept or dismiss them before the browser times out.
Playwright intercepts these dialogs through the page.on('dialog') event listener. When a dialog appears, Playwright captures it, allowing you to inspect its message, type, and take action. This is fundamentally different from Selenium's alert handling, which requires switching contexts; Playwright's event-driven approach is cleaner and more reliable.
Let's start with a practical scenario. Imagine a user deletes an account and gets a confirmation dialog: "Are you sure? This cannot be undone." Your test needs to dismiss it programmatically.
// Set up the dialog handler before triggering the action
page.on('dialog', async (dialog) => {
console.log(`Dialog type: ${dialog.type()}`);
console.log(`Dialog message: ${dialog.message()}`);
await dialog.dismiss();
});
// Now trigger the action that shows the dialog
await page.click('button:has-text("Delete Account")');
// The test continues—the dialog was handled automatically
await expect(page).toHaveURL('/account-deleted');
The key insight: register the listener before the action that triggers the dialog. Playwright queues the dialog event, so even if your code reaches the dialog listener slightly after the dialog appears, you won't miss it.
Using page.on('dialog') to accept or dismiss
Every dialog has two core methods: accept() and dismiss(). Some dialogs (prompts) also accept user input via accept(text). The dialog type tells you what you're dealing with: 'alert', 'confirm', 'prompt', or 'beforeunload'.
Here's a multi-scenario example from a real SaaS workflow:
// Scenario 1: Accept an alert
page.on('dialog', async (dialog) => {
if (dialog.type() === 'alert') {
console.log('Alert:', dialog.message());
await dialog.accept();
}
});
await page.click('button:has-text("Save Changes")');
// Scenario 2: Handle a confirm with conditional logic
page.once('dialog', async (dialog) => {
if (dialog.message().includes('Delete')) {
await dialog.accept();
} else {
await dialog.dismiss();
}
});
await page.click('button:has-text("Remove Item")');
// Scenario 3: Answer a prompt
page.once('dialog', async (dialog) => {
if (dialog.type() === 'prompt') {
await dialog.accept('NewProjectName');
}
});
await page.click('button:has-text("Duplicate Project")');
Notice the difference between page.on('dialog') and page.once('dialog'). Use on for listeners that should persist across multiple dialogs (e.g., a generic handler that accepts all alerts). Use once for one-time dialogs you expect immediately. In most cases, on is safer because it doesn't require perfect timing.
Always call either accept() or dismiss(). If you don't handle the dialog, the page will hang until the test timeout fires, causing a confusing failure.
Handling new tabs and popup windows with waitForEvent
When a user clicks a link with target="_blank" or JavaScript opens a new window, Playwright doesn't automatically switch to it. You need to capture the popup using waitForEvent('popup'), which returns a reference to the new page object.
This is a critical pattern in e-commerce testing. Consider a checkout flow where clicking "Pay Now" opens a payment gateway in a new tab:
// Set up a listener for the popup before triggering it
const popupPromise = page.waitForEvent('popup');
// Click the button that opens the popup
await page.click('button:has-text("Pay Now")');
// Wait for the popup and get a reference to it
const popup = await popupPromise;
// Now you can interact with the new page
await popup.waitForLoadState('networkidle');
console.log('Popup URL:', popup.url());
// Verify we're on the payment provider's site
await expect(popup).toHaveURL(/stripe\.com|paypal\.com/);
The order matters: set up waitForEvent first, then trigger the action. Otherwise, the popup appears before the listener is ready, and you'll miss it.
In some frameworks, you might have multiple popups. Use an array to collect them:
const popups = [];
page.on('popup', (popup) => popups.push(popup));
// Trigger multiple new tabs
await page.click('a[target="_blank"]'); // First popup
await page.click('button:has-text("Share")'); // Second popup
// Wait for both to load
await Promise.all(popups.map((p) => p.waitForLoadState('networkidle')));
console.log(`Opened ${popups.length} tabs`);
Asserting content in the new page
Once you have a popup reference, treat it like any other Page object. You can navigate, click, fill forms, and assert content. This is essential in integration tests involving third-party services.
A real-world example: your app integrates with a payment processor. You need to verify that the iframe or redirect page displays the correct order amount and merchant name.
const page = await browser.newPage();
await page.goto('https://shop.example.com/checkout');
// Fill in order details
await page.fill('input[name="amount"]', '99.99');
await page.fill('input[name="item"]', 'Premium Subscription');
// Open the payment popup
const popupPromise = page.waitForEvent('popup');
await page.click('button:has-text("Proceed to Payment")');
const popup = await popupPromise;
// Verify the popup is on the payment provider's domain
expect(popup.url()).toContain('payment-provider.com');
// Assert the order summary appears in the popup
await expect(popup.locator('text=99.99')).toBeVisible();
await expect(popup.locator('text=Premium Subscription')).toBeVisible();
// Simulate payment completion (mock or real, depending on setup)
await popup.fill('input[name="cardNumber"]', '4111111111111111');
await popup.click('button:has-text("Pay Now")');
// Wait for redirect back to your app
await page.waitForURL('**/order-confirmation');
await expect(page.locator('text=Payment Successful')).toBeVisible();
Notice we're using the popup page object as a first-class citizen. This pattern works for iframes in new tabs, redirect flows, and SSO dialogs.
Closing popups to keep tests clean
After you're done asserting on a popup, close it. This keeps your test isolated and prevents resource leaks. Closing a popup also dismisses any unhandled dialogs it might contain, reducing flakiness.
const popup = await page.waitForEvent('popup');
await popup.waitForLoadState('networkidle');
// Run your assertions
await expect(popup.locator('h1')).toContainText('Expected Title');
// Clean up: close the popup
await popup.close();
// Optionally verify the original page is still active
await expect(page.locator('body')).toBeVisible();
In multi-popup scenarios, close them in reverse order (last opened, first closed) to avoid accidental focus shifts:
const popup1 = await page.waitForEvent('popup');
const popup2 = await page.waitForEvent('popup');
// Run assertions on both...
// Close in reverse order
await popup2.close();
await popup1.close();
If you're running tests in parallel (multiple test instances), each context isolates popups automatically. However, if a single test opens many popups without closing them, your machine's resource usage will spike, potentially causing timeouts or crashes. Always clean up.
Common mistakes
- Not registering the dialog listener before the action: Set up
page.on('dialog')orwaitForEvent('popup')before the click or navigation that triggers the dialog. Reverse order = missed events. - Forgetting to call accept() or dismiss(): If you capture a dialog but don't respond to it, the page hangs until timeout. Always handle every dialog.
- Using page.once() when you should use page.on():
onceremoves the listener after the first event. If your app shows dialogs multiple times, the second one won't be handled. Useonunless you're 100% certain there's only one dialog. - Not waiting for popup content to load: Calling
popup.waitForLoadState('networkidle')orload'before asserting ensures the DOM is ready. Skipping this causes flaky assertions. - Mixing dialog handling across tests: If Test A sets up a dialog listener and doesn't clean it up, Test B might accidentally trigger it. Always unregister listeners or use fresh pages per test.
- Assuming popup URLs match exactly: New tabs often redirect. Use regex patterns (`/paypal\.com/`) instead of exact strings to handle redirects.
Key takeaways
- Intercept dialogs with
page.on('dialog')before the action that triggers them, then callaccept()ordismiss()to unblock the page. - Capture new tabs and popup windows with
waitForEvent('popup'), which returns a page object you can interact with like any other. - Treat popup page objects as first-class; you can navigate, fill forms, assert content, and inspect their URLs just as you would the main page.
- Always close popups after testing to free resources and keep tests isolated. Use
popup.close()explicitly. - For robustness, set up event listeners early, use regex patterns for URL matching, and wait for load states before asserting content.
- Test dialog and popup flows in isolation before integrating them into larger workflows; this makes debugging easier when something breaks.






Comments
Loading comments…