10Popups, Alerts, Events & Windows (deep-dive)
What you will master here
- Every event a page/context emits
- JS dialogs: alert, confirm, prompt, beforeunload
- Popups and new tabs — opener relationships
- Switching between multiple pages programmatically
- The tricky case: new tab opens, and a JS dialog fires immediately before you can attach a listener
- Cross-origin popups (OAuth, payment redirects)
- Window.open vs target=_blank vs window.opener — the differences that matter for tests
10.1 Every event you can listen to
On page
| Event | Fires when |
|---|---|
dialog | alert/confirm/prompt/beforeunload opens |
popup | this page opens a popup/new tab via window.open |
download | file download starts |
filechooser | <input type=file> chooser opens (or button click triggers it) |
request / response / requestfinished / requestfailed | network lifecycle |
console | page calls console.log/warn/error |
pageerror | uncaught JS exception |
frameattached / framedetached / framenavigated | iframe lifecycle |
load / domcontentloaded | standard browser events |
close | page closes |
crash | page process dies |
worker / websocket | worker / WS opened |
On context
page— any new Page opens (any source: window.open, target=_blank, in-test newPage)request/response— across all pages in the contextclose
On browser
disconnected
10.2 JS dialogs — alert / confirm / prompt
These BLOCK the page until handled. Playwright auto-dismisses them by default (so tests don't hang) unless you attach a page.on('dialog', ...) listener.
// Accept any confirm/alert
page.on('dialog', async dialog => {
console.log(`${dialog.type()}: ${dialog.message()}`);
if (dialog.type() === 'prompt') await dialog.accept('the answer');
else if (dialog.type() === 'confirm') await dialog.accept();
else await dialog.dismiss();
});
await page.getByRole('button', { name: 'Delete account' }).click();
/* listener will fire when the confirm appears */
Dialog types
| type() | What it is | accept() / dismiss() effect |
|---|---|---|
| alert | One-button notice | accept = OK click. dismiss = also closes (no Cancel). |
| confirm | OK / Cancel | accept = OK. dismiss = Cancel. |
| prompt | Text input | accept(text) = OK with value. dismiss = Cancel. |
| beforeunload | Browser's "Leave site?" dialog | accept = stay (default). dismiss = leave. Triggered by onbeforeunload. |
page.on(...) handles all dialogs forever. page.once(...) handles only the next one. Use once when you want to handle one prompt then assert the next is unhandled.10.3 Popups and new tabs
Three things can open a "new page":
window.open(url)— JS-initiated<a target="_blank">— anchor click opens new tab- Middle-click / Cmd-click on a link — same effect
In Playwright, all three appear as a new Page in the same context. Listen via context.waitForEvent('page') (any source) or page.waitForEvent('popup') (specifically from this page).
// Generic: catch any new page
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('#openSomething'),
]);
// Specifically: catch popup from this page only
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.click('#openPopup'),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/terms/);
10.4 Switching between multiple pages
Pages list is on the context. You can switch by index, URL, or title.
// All pages in a context const pages = context.pages(); console.log(pages.length); // 1 initially, grows as popups open // Get the most recent const latest = pages[pages.length - 1]; // Find by URL pattern const checkout = pages.find(p => /\/checkout/.test(p.url())); // Bring a page to front (useful when running headed) await checkout.bringToFront(); // Close one await pages[1].close();
10.5 The tricky case — new tab opens with a dialog already pending
Scenario: clicking a link opens a new tab. Inside that new tab's page load, JS fires alert('Welcome!') immediately. By the time you get the Page reference and try to attach a dialog listener, the alert has already fired (or is about to). How do you handle it cleanly?
Solution — attach listener BEFORE you await any navigation on the new page
test('new tab opens with an immediate alert', async ({ context, page }) => {
// 1. Trigger the new tab AND grab its Page object atomically
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open new tab' }).click(),
]);
// 2. CRITICAL: attach the dialog listener BEFORE waiting for load.
// The new page is created but its scripts haven't run yet — we're
// racing the alert. Registering the handler now guarantees we catch it.
popup.on('dialog', d => {
console.log('dialog on new tab:', d.type(), d.message());
d.accept();
});
// 3. Now wait for load — the alert fires during this, the handler accepts.
await popup.waitForLoadState('domcontentloaded');
// 4. Continue with assertions
await expect(popup.getByRole('heading')).toHaveText('Welcome');
});
context.waitForEvent('page') event fires the instant the new Page object exists, before its scripts start running. That gives you a window to attach the dialog handler. Doing anything that yields (an await) before attaching the handler risks the alert firing first.Same pattern in Python
def test_new_tab_with_alert(context, page):
with context.expect_page() as info:
page.get_by_role("link", name="Open new tab").click()
popup = info.value
# Attach BEFORE waiting for load
popup.on("dialog", lambda d: d.accept())
popup.wait_for_load_state("domcontentloaded")
expect(popup.get_by_role("heading")).to_have_text("Welcome")
10.6 Worse case — TWO popups stacked
What if you trigger a button that opens both a popup and a dialog at the same time, but the dialog is on the original page (not the popup)?
test('original page shows confirm, popup opens', async ({ context, page }) => {
// Attach handlers BEFORE the action
page.once('dialog', d => d.accept()); // confirm on original page
const [popup] = await Promise.all([
context.waitForEvent('page'), // new tab
page.getByRole('button', { name: 'Go' }).click(),
]);
await popup.waitForLoadState();
await expect(popup).toHaveURL(/\/destination/);
});
10.7 OAuth / payment popup flow
OAuth login or 3DS payment commonly: click "Sign in with Google" → popup opens → user authenticates → popup closes itself → original page navigates. Test this as:
test('sign in via Google popup', async ({ context, page }) => {
await page.goto('/login');
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('button', { name: 'Continue with Google' }).click(),
]);
// Authenticate inside the popup
await popup.getByLabel('Email').fill('qa@test.com');
await popup.getByRole('button', { name: 'Next' }).click();
await popup.getByLabel('Password').fill(process.env.GOOGLE_PASS!);
await popup.getByRole('button', { name: 'Sign in' }).click();
// Popup closes itself; wait for original page to settle
await page.waitForURL(/\/dashboard/);
await expect(page.getByText('Welcome')).toBeVisible();
});
page.route, (3) bypass via storageState seeded once.10.8 beforeunload — "are you sure you want to leave?"
test('unsaved-changes prompt blocks leave', async ({ page }) => {
await page.goto('/form');
await page.getByLabel('Description').fill('important draft');
// dialog handler must be registered before triggering the unload
page.once('dialog', d => d.dismiss()); // dismiss = stay on page
await page.goto('/').catch(() => {}); // navigation aborted
await expect(page.getByLabel('Description')).toHaveValue('important draft');
});
10.9 window.opener and window.opener.postMessage
A popup can communicate back to its opener via window.opener.postMessage(...). To test it, listen on the original page:
// Eavesdrop on postMessage from the popup
const messages: any[] = [];
await page.exposeFunction('onMsg', (m: any) => messages.push(m));
await page.evaluate(() => window.addEventListener('message', (e) => (window as any).onMsg(e.data)));
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.click('#openPopup'),
]);
await popup.evaluate(() => (window.opener as any).postMessage({ type: 'auth-done', token: 'abc' }, '*'));
await expect.poll(() => messages.length).toBeGreaterThan(0);
expect(messages[0]).toMatchObject({ type: 'auth-done' });
10.10 Common pitfalls
- Forgetting promise-first — the event fires before your listener is registered.
- Awaiting the popup load before attaching dialog handler — alert fires during load, handler missed.
- Using
page.onwhenpage.onceis right — multiple handlers stack, each fires. - Looking for the popup on the wrong page — popups can be opened by ANY page in the context; listen on context, not just page, if unsure.
- Not closing popups — they accumulate, eat memory in long suites. Call
popup.close()at the end of the test. - Cross-origin assumptions — Same-Origin Policy means you can't directly access window.opener of a different origin from your test. Use postMessage.
Module 22 — Popups/Dialogs Q&A bank
What dialog types exist and what do accept/dismiss do?
How do you handle a dialog?
page.on('dialog', d => d.accept()) BEFORE the action that triggers it. If only one dialog will fire, use page.once. Without a listener, Playwright auto-dismisses to prevent hangs.How do you handle a popup / new tab?
const [popup] = await Promise.all([context.waitForEvent('page'), page.click(...)]). The new Page is captured before navigation completes; assert on it as a normal page.How do you switch between multiple open pages?
context.pages() returns an array of all pages. Index, find by URL, or use bringToFront() on the one you want to focus. Each Page is independent — your locators/actions apply to whichever you call them on.If a new tab opens and a JS alert fires immediately on load, how do you handle it?
context.waitForEvent('page'). Immediately, with no intervening await, attach popup.on('dialog', d => d.accept()). THEN await loadstate. The Page object exists before its scripts run, so registering the handler first guarantees you catch the alert.What's the difference between page.waitForEvent('popup') and context.waitForEvent('page')?
How do you handle beforeunload?
page.once('dialog', d => d.dismiss()) (dismiss = stay) or d.accept() (leave). Triggered when you navigate away while window.onbeforeunload returns a truthy value.How do you avoid stacking dialog handlers across tests?
once over on when only one dialog is expected.What happens if no dialog handler is attached?
How do you test an OAuth popup flow?
What is window.opener?
rel="noopener" for security and use postMessage explicitly.How do you assert that a popup sent a postMessage to its opener?
page.exposeFunction, register a window message listener that calls it. Trigger the popup action; assert on the captured message via expect.poll.What if the same button opens TWO new tabs in sequence?
context.waitForEvent('page') twice (once for each). For three or more, switch to context.on('page', handler) for the duration of the action.