QA How-To
How to Use Playwright dialog handling (2026)
Master Playwright dialog handling for alerts, confirms, prompts, beforeunload, popups, assertions, reusable helpers, debugging, and reliable test design.
22 min read | 2,417 words
TL;DR
Playwright dialog handling uses the page or browser-context `dialog` event. If no listener exists, alert, confirm, and prompt dialogs are automatically dismissed; once a listener exists, it must call `dialog.accept()` or `dialog.dismiss()` so the page can continue. Register before the triggering action and assert the resulting application state after handling.
Key Takeaways
- Playwright automatically dismisses JavaScript dialogs only when no page or context dialog listener is registered.
- Every registered dialog handler must accept or dismiss the dialog, or the triggering action can stall.
- Start listening before the action and coordinate observation, handling, and the action in one reliable sequence.
- Use dialog type, message, and default value assertions to prove the correct browser dialog opened.
- Treat beforeunload and print as special cases with different completion behavior.
- Use DOM locators for application modals because they are not JavaScript Dialog objects.
Playwright dialog handling is based on the dialog event emitted when a page opens a JavaScript alert, confirm, prompt, or beforeunload confirmation. The essential rule is simple: if you register a dialog listener, that listener must accept or dismiss the dialog. Otherwise the browser remains blocked and an action such as locator.click() can wait until the test times out.
Browser dialogs are not HTML modals. They do not have DOM buttons that Playwright can locate, and they block page script while open. This guide shows safe event sequencing, assertions, prompt input, repeated dialogs, beforeunload, print testing, and helper design for reliable TypeScript suites.
TL;DR
import { test, expect } from '@playwright/test';
test('accepts a confirmation and verifies deletion', async ({ page }) => {
await page.goto('/projects/PRJ-42');
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toBe('Delete project PRJ-42?');
await dialog.accept();
}),
page.getByRole('button', { name: 'Delete project' }).click(),
]);
await expect(page.getByRole('status')).toHaveText('Project deleted');
});
| Browser event | dialog.type() |
Typical action | Important check |
|---|---|---|---|
alert(message) |
alert |
accept() |
Message and resulting state |
confirm(message) |
confirm |
accept() or dismiss() |
Both product branches |
prompt(message, default) |
prompt |
accept(text) or dismiss() |
Message and default value |
| Page unload confirmation | beforeunload |
accept() or dismiss() |
Whether page closes or stays |
window.print() |
Not a normal Dialog |
Instrument window.print |
Invocation of print behavior |
1. How Playwright Dialog Handling Works
A JavaScript dialog is modal at the browser level. While it is open, the page's JavaScript execution is blocked. Playwright exposes a Dialog object through page.on('dialog'), page.once('dialog'), page.waitForEvent('dialog'), and the corresponding browser-context event. The object provides type(), message(), defaultValue(), accept(), dismiss(), and page().
When neither the page nor its browser context has a dialog listener, Playwright automatically dismisses dialogs. That default keeps an unexpected alert from freezing every test. It also means a test that simply clicks an alert-triggering button without a listener exercises the dismissal branch. If the scenario needs acceptance, message validation, prompt text, or evidence that a dialog opened, install a handler first.
The behavior changes as soon as a listener exists. A listener that only logs the message leaves the dialog open, so the action that triggered it cannot finish. This is a classic timeout cause:
// Wrong: the click can stall because the listener never handles the dialog.
page.on('dialog', dialog => console.log(dialog.message()));
await page.getByRole('button', { name: 'Delete' }).click();
The correct handler always resolves the modal state:
page.on('dialog', async dialog => {
console.log(dialog.message());
await dialog.dismiss();
});
Use a persistent on listener only when a page can legitimately produce several dialogs with the same policy. For a single expected event, a one-shot wait is easier to reason about and prevents the handler from silently affecting later steps.
2. Set Up Playwright Dialog Handling Before the Trigger
Event ordering is the foundation of reliable dialog tests. Start waiting before clicking the control that opens the dialog. Because the click itself may not resolve until the dialog closes, the wait must also handle the dialog while the action is in progress. Promise.all gives both operations a coordinated lifetime.
import { test, expect } from '@playwright/test';
test('acknowledges a maintenance alert', async ({ page }) => {
await page.goto('/system-status');
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('alert');
expect(dialog.message()).toBe('Maintenance starts at 22:00 UTC');
await dialog.accept();
}),
page.getByRole('button', { name: 'Show maintenance notice' }).click(),
]);
await expect(page.getByRole('status')).toHaveText('Notice acknowledged');
});
This sequence avoids two races. The listener exists before the browser event, and the event handler accepts the dialog before the click must finish. An alternative is to start the click promise without awaiting it, await the dialog, handle it, and then await the click. Promise.all is usually more compact.
Do not put an assertion before accept or dismiss if a failed assertion would strand the browser. The then handler above can fail before accepting if type or message is wrong, but Playwright will terminate the failed test and dispose its context. For reusable long-lived contexts, use try and finally so the handler dismisses even when validation fails. Most Playwright Test suites use per-test contexts, making the direct pattern readable and contained.
After handling, assert the user-visible consequence. Observing an alert proves the event, but not that the application updated, navigated, deleted, or retained data correctly. Web-first assertions provide the business evidence.
3. Handle Alerts and Validate Their Messages
An alert has one browser action, acknowledgment. Both accept() and the browser's OK path close it; dismiss() also closes an alert in automation, but accept() communicates intent more clearly. Assert the exact message when the copy is a contractual warning. Use a regular expression when dynamic but controlled data is included.
test('shows the invoice identifier in the alert', async ({ page }) => {
await page.goto('/invoices/INV-1048');
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('alert');
expect(dialog.message()).toMatch(/^Invoice INV-1048 was exported$/);
expect(dialog.defaultValue()).toBe('');
await dialog.accept();
}),
page.getByRole('button', { name: 'Export invoice' }).click(),
]);
await expect(page.getByRole('status')).toHaveText('Export ready');
});
For non-prompt dialogs, defaultValue() returns an empty string. That assertion is rarely necessary in normal alert tests, but it documents the API behavior. Avoid snapshots of dialog text. Exact or pattern assertions produce a much clearer failure.
If the application unexpectedly opens an alert, Playwright's automatic dismissal can hide it unless the test asserts the resulting branch. A diagnostic fixture can listen for unexpected dialogs, record their type and message, dismiss them, and fail the test afterward. Design that fixture carefully so it never leaves the page blocked and does not conflict with tests that intentionally own dialog events.
Alerts often indicate a legacy or emergency UI pattern. From a testing perspective, validate accessibility and product requirements, but do not attempt to locate an OK button. Browser-native dialog controls sit outside the document DOM.
4. Test Both Confirm Dialog Branches
A confirm returns true to page script when accepted and false when dismissed. Both branches are product behavior. A destructive action should usually have one test proving cancellation preserves the record and another proving acceptance completes the operation. Keep data unique so parallel cases cannot delete each other's fixture.
import { test, expect } from '@playwright/test';
test('keeps the saved search when deletion is cancelled', async ({ page }) => {
await page.goto('/saved-searches');
const row = page.getByRole('row', { name: /Remote SDET roles/ });
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toBe('Delete saved search?');
await dialog.dismiss();
}),
row.getByRole('button', { name: 'Delete' }).click(),
]);
await expect(row).toBeVisible();
await expect(page.getByRole('status')).toHaveText('Deletion cancelled');
});
test('deletes the saved search after confirmation', async ({ page }) => {
await page.goto('/saved-searches?fixture=deletable');
const row = page.getByRole('row', { name: /Contract QA roles/ });
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('confirm');
await dialog.accept();
}),
row.getByRole('button', { name: 'Delete' }).click(),
]);
await expect(row).toHaveCount(0);
});
Do not infer the branch from the dialog method alone. Assert persistence through the UI or an API if deletion integrity matters. For a deeper negative-testing approach, see API error handling and negative testing.
If acceptance triggers a network request, wait for the visible result or a specific response in addition to dialog handling. Do not use waitForTimeout to guess when deletion finishes.
5. Enter and Verify Prompt Values
A prompt can expose initial text and return a string, an empty string, or null depending on user action. dialog.defaultValue() reads the proposed input. dialog.accept(promptText) supplies the accepted value. The text argument has no effect on non-prompt dialog types, so assert type() before relying on it.
test('renames a dashboard from a prompt', async ({ page }) => {
await page.goto('/dashboards/quality-overview');
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe('prompt');
expect(dialog.message()).toBe('Enter a dashboard name');
expect(dialog.defaultValue()).toBe('Quality overview');
await dialog.accept('Release quality overview');
}),
page.getByRole('button', { name: 'Rename dashboard' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Release quality overview' }))
.toBeVisible();
});
Also test cancellation if the product must preserve the prior value:
await Promise.all([
page.waitForEvent('dialog').then(dialog => dialog.dismiss()),
page.getByRole('button', { name: 'Rename dashboard' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Quality overview' }))
.toBeVisible();
Use boundary data that reflects the product's validation: empty accepted value, leading spaces, maximum length, Unicode, or prohibited characters. A native prompt offers limited inline validation, so verify how page code sanitizes and reports invalid input. Never place secrets, personal production data, or access tokens in test prompt values or artifacts.
For forms that use HTML inputs instead, use label locators and fill; no dialog handler is involved. The Playwright getByLabel guide covers accessible form targeting.
6. Manage Multiple, Conditional, and Unexpected Dialogs
Some legacy flows open a sequence of dialogs, such as a confirmation followed by an alert. A persistent handler can apply a policy based on the observed type and order, but the test must know when all expected dialogs have finished. Capture serializable observations and use a retrying assertion on their count.
test('handles confirm followed by completion alert', async ({ page }) => {
const observed: Array<{ type: string; message: string }> = [];
page.on('dialog', async dialog => {
observed.push({ type: dialog.type(), message: dialog.message() });
if (dialog.type() === 'confirm') {
await dialog.accept();
return;
}
await dialog.accept();
});
await page.goto('/imports');
await page.getByRole('button', { name: 'Replace existing import' }).click();
await expect.poll(() => observed).toEqual([
{ type: 'confirm', message: 'Replace the current import?' },
{ type: 'alert', message: 'Import replaced' },
]);
await expect(page.getByRole('status')).toHaveText('Import complete');
});
This handler accepts all dialogs, so limit it to the test or describe scope. A global accept-all listener can turn an unexpected warning into a false pass. If an unexpected type or message should fail, record the error, close the dialog safely, then throw or assert after the triggering operation. Avoid throwing before handling because the modal can keep a shared browser frozen.
Conditional dialogs also expose flawed test setup. If the confirmation appears only when data exists, create the fixture explicitly and assert the event. Do not use a handler that quietly accepts any possible dialog and then claim the confirmation path was covered.
Remove listeners from manually shared pages when their policy ends. Playwright Test's isolated page fixture is disposed after each test, but custom worker-scoped pages can retain event listeners and create cross-test behavior.
7. Handle Dialogs from Popups, Frames, and Browser Contexts
A dialog belongs to the page that opened it, even if page script inside an iframe triggered the JavaScript call. A page listener handles dialogs initiated by any frame within that page. A popup is a different Page, so attach to the popup or listen at the browser-context level when several pages can produce the event.
test('handles a confirmation opened by a popup', async ({ page }) => {
await page.goto('/billing');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Manage payment method' }).click();
const popup = await popupPromise;
await popup.waitForLoadState('domcontentloaded');
await Promise.all([
popup.waitForEvent('dialog').then(async dialog => {
expect(dialog.page()).toBe(popup);
expect(dialog.type()).toBe('confirm');
await dialog.accept();
}),
popup.getByRole('button', { name: 'Remove payment method' }).click(),
]);
await expect(popup.getByRole('status')).toHaveText('Payment method removed');
});
dialog.page() returns the initiating page when available, which is useful in context-level handlers. A context listener can observe dialogs from current and future pages in that context:
context.on('dialog', async dialog => {
console.log(dialog.page()?.url(), dialog.message());
await dialog.dismiss();
});
Use that broad scope for logging or a deliberate shared policy, not by default. A page-specific one-shot wait gives better ownership and reduces accidental interference.
If the control is inside an iframe but the dialog belongs to the top page, trigger it through a frameLocator, while waiting on the Page's dialog event. For iframe locator patterns, see Playwright iframe testing.
8. Test beforeunload Without Waiting for Normal Close Semantics
beforeunload is special. By default, page.close() does not run unload handlers. Pass { runBeforeUnload: true } to execute them. In that mode, page.close() does not wait for the page to close because the dialog might be dismissed and leave the page open. Register a handler before closing.
import { test, expect } from '@playwright/test';
test('keeps an editor open when leaving is cancelled', async ({ page }) => {
await page.goto('/editor/new');
await page.getByLabel('Article body').fill('Unsaved draft');
let observedType: string | undefined;
page.once('dialog', async dialog => {
observedType = dialog.type();
await dialog.dismiss();
});
await page.close({ runBeforeUnload: true });
await expect.poll(() => observedType).toBe('beforeunload');
expect(page.isClosed()).toBe(false);
});
Whether the browser shows a custom message is restricted by modern browser behavior. Do not assert application-supplied beforeunload wording as though every browser will display it. Assert the type and the resulting close or stay behavior.
An acceptance case can call dialog.accept() and then wait for the page close event if the application and browser proceed. Coordinate this carefully because page.close({ runBeforeUnload: true }) itself returns before final closure. Always ensure the page is disposed in test cleanup, especially when dismissal intentionally leaves it open.
A DOM-based unsaved-changes modal is different. It can be located and clicked normally, offers accessible custom content, and may be preferred by product design. Identify whether the application uses a browser beforeunload confirmation, an HTML dialog, or both before writing the test.
9. Test Print Behavior and Distinguish DOM Modals
window.print() is not tested by waiting for the same JavaScript dialog event. Native print UI is outside the DOM and may behave differently in headless environments. To assert that the page requests printing, instrument window.print before the click and wait for the replacement function to run.
test('requests printing from the invoice action', async ({ page }) => {
await page.goto('/invoices/INV-1048');
await page.evaluate(() => {
const state = window as typeof window & {
waitForPrintDialog?: Promise<void>;
};
state.waitForPrintDialog = new Promise(resolve => {
window.print = () => resolve();
});
});
await page.getByRole('button', { name: 'Print invoice' }).click();
await page.evaluate(() => {
const state = window as typeof window & {
waitForPrintDialog?: Promise<void>;
};
return state.waitForPrintDialog;
});
});
The official pattern replaces window.print with a resolver and establishes it before the action. For printable layout, separately call page.emulateMedia({ media: 'print' }) and assert the rendered page or create a PDF in a supported Chromium workflow. Printing invocation and print layout are different contracts.
HTML <dialog> elements, React modals, confirmation sheets, and library popovers remain part of the DOM. Use getByRole('dialog'), buttons, focus assertions, and normal Playwright locators. Registering page.on('dialog') will not see them. If a test times out waiting for a dialog event while a styled modal is visibly open, inspect the DOM role before changing timeouts.
The Playwright event handling guide provides related patterns for popups, downloads, requests, and other events that must be awaited before their trigger.
10. Build Reusable Playwright Dialog Handling Without Hiding Intent
A helper can coordinate a single dialog when many tests need the same sequencing, but it should keep expected type, message, decision, and trigger explicit. Avoid a global accept every dialog fixture. That policy hides unexpected warnings and makes destructive tests dangerous.
import { expect, Page } from '@playwright/test';
type DialogDecision =
| { action: 'accept'; promptText?: string }
| { action: 'dismiss' };
export async function runWithDialog(
page: Page,
expected: { type: string; message: string | RegExp },
decision: DialogDecision,
trigger: () => Promise<unknown>,
+) {
await Promise.all([
page.waitForEvent('dialog').then(async dialog => {
expect(dialog.type()).toBe(expected.type);
if (typeof expected.message === 'string') {
expect(dialog.message()).toBe(expected.message);
} else {
expect(dialog.message()).toMatch(expected.message);
}
if (decision.action === 'accept') {
await dialog.accept(decision.promptText);
} else {
await dialog.dismiss();
}
}),
trigger(),
]);
}
Usage remains readable:
await runWithDialog(
page,
{ type: 'confirm', message: 'Archive this report?' },
{ action: 'accept' },
() => page.getByRole('button', { name: 'Archive report' }).click(),
);
await expect(page.getByRole('status')).toHaveText('Report archived');
Keep product-specific assertions after the helper. Type could be narrowed to the four known dialog values in a mature utility. Add timeout and diagnostic behavior only when the repository needs it, and test the helper itself with a fixture page. The best abstraction removes event ceremony while preserving scenario intent.
Interview Questions and Answers
Q: What happens if no dialog listener is registered in Playwright?
Playwright automatically dismisses JavaScript dialogs. Once a page or context dialog listener exists, the listener must accept or dismiss each dialog. Otherwise the modal blocks page execution and the triggering action can stall.
Q: Why must the listener be registered before clicking?
The dialog event can fire synchronously during the click's page-side effect. Registering afterward creates a race and may miss the event. I coordinate a one-shot event wait and the trigger so the handler closes the dialog while the action is pending.
Q: How do you test a confirm dialog?
I assert its type and message, then test both accept() and dismiss() in isolated scenarios. After each decision, I assert the persisted product result, such as record deletion or preservation. The dialog event alone is not enough business evidence.
Q: How do you supply text to a prompt?
I verify dialog.type() is prompt, optionally assert defaultValue(), then call dialog.accept('new value'). After handling, I assert the page used the accepted value. Cancellation uses dismiss() and should preserve the prior state if that is the contract.
Q: How is beforeunload different from a normal confirm?
page.close() skips unload handlers unless runBeforeUnload is true. With it enabled, close does not wait for final closure because dismissing the confirmation can keep the page open. I install the handler first and assert the resulting closed or open state.
Q: Does page dialog handling work for a React modal?
No. A React modal or HTML dialog is DOM content, so I use role locators and normal actions. The Playwright dialog event is for browser JavaScript dialogs such as alert, confirm, prompt, and beforeunload.
Q: How would you handle an unexpected dialog without hiding it?
A diagnostic listener records type and message, safely dismisses the dialog, and causes the test to fail with that evidence. Intentional dialog tests own their one-shot handler so the diagnostic policy does not compete with them. I never leave an unexpected modal open.
Common Mistakes
- Registering a listener that logs the dialog but never accepts or dismisses it.
- Starting to wait after the triggering click and missing a synchronous event.
- Awaiting a blocking click before obtaining and handling its dialog.
- Using one global accept-all handler that hides unexpected warnings.
- Asserting only that a dialog opened and skipping the product result.
- Passing prompt text without first verifying the dialog is a prompt.
- Trying to locate native alert buttons with DOM selectors.
- Waiting for a
dialogevent for a React or HTML modal. - Treating
window.print()as an ordinary Playwright Dialog. - Expecting
page.close({ runBeforeUnload: true })to wait for normal close completion.
Conclusion
Reliable Playwright dialog handling depends on event ownership and sequence. Listen before the trigger, inspect the type and message, accept or dismiss every observed browser dialog, then assert the user-visible or persisted result. Use one-shot handling for isolated scenarios and broad context listeners only when several pages truly share a policy.
Start by replacing any log-only or accept-all listener in the suite. Give each alert, confirm, prompt, and unload branch an explicit decision and outcome, and use ordinary DOM locators for application modals so each test targets the interface that actually exists.
Interview Questions and Answers
Explain Playwright's default dialog behavior.
Without a page or context dialog listener, Playwright automatically dismisses JavaScript dialogs. With a listener, the test owns the event and must accept or dismiss it. A handler that only observes the message can leave the page blocked.
What is the safest sequence for handling a dialog caused by a click?
I register a one-shot event wait before the click and coordinate both operations. The event branch validates and handles the Dialog while the click is pending. After both complete, I assert the application outcome with a web-first assertion.
Which methods and properties are important on a Playwright Dialog?
I use `type()`, `message()`, and for prompts `defaultValue()` to inspect it. I close it with `accept()`, optional prompt text, or `dismiss()`. `page()` can identify the initiating page in multi-page handlers.
How would you test both branches of a confirm?
I create isolated data for an acceptance case and a cancellation case. Each test verifies type and message, makes the decision, and asserts durable state after the dialog closes. This covers the JavaScript return value through its product effect.
Why is an accept-all listener risky?
It can accept unexpected destructive confirmations or warnings and turn a defect into a pass. It also affects later actions if the page is reused. I prefer a scoped one-shot handler with an expected type and message.
How does beforeunload testing differ from alert testing?
Unload handlers run on close only when `runBeforeUnload` is enabled. The close method then returns without waiting for final closure because dismissal may keep the page open. I observe the beforeunload type and separately verify the page state.
How do you distinguish a browser dialog from a web modal?
A browser dialog comes from alert, confirm, prompt, or beforeunload and arrives through the `dialog` event outside the DOM. A web modal is rendered markup, often with a dialog role, and is handled through locators. Inspecting the DOM and event behavior identifies the correct model.
Frequently Asked Questions
How does Playwright handle alerts by default?
Playwright automatically dismisses JavaScript dialogs when no page or browser-context dialog listener is present. If you add a listener, it must accept or dismiss the dialog so the page can continue.
How do I accept a confirm dialog in Playwright?
Start waiting for the page's `dialog` event before the triggering action, assert the dialog type and message, and call `dialog.accept()`. Coordinate handling with the action because the click can remain pending while the dialog is open.
How do I dismiss a Playwright dialog?
Call `dialog.dismiss()` inside the registered event handler. Then assert the cancellation result in the page, such as the record remaining present or the operation staying unchanged.
How do I enter text in a JavaScript prompt with Playwright?
After verifying the dialog type is `prompt`, call `dialog.accept('your text')`. Use `dialog.defaultValue()` if the prompt's initial value is part of the behavior under test.
Why does my Playwright test hang after an alert appears?
The usual cause is a registered dialog listener that does not call `accept()` or `dismiss()`. A browser dialog blocks page execution, so the action that opened it cannot complete until the listener handles it.
Can Playwright handle beforeunload dialogs?
Yes. Register a dialog handler and call `page.close({ runBeforeUnload: true })`. That close call does not wait for final page closure, so assert whether the page remains open or eventually closes based on the decision.
Is a React modal handled with page.on dialog?
No. React modals and HTML dialogs are DOM elements. Locate them by role and interact with their buttons through normal Playwright locators.