Popups, Alerts, Windows

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

EventFires when
dialogalert/confirm/prompt/beforeunload opens
popupthis page opens a popup/new tab via window.open
downloadfile download starts
filechooser<input type=file> chooser opens (or button click triggers it)
request / response / requestfinished / requestfailednetwork lifecycle
consolepage calls console.log/warn/error
pageerroruncaught JS exception
frameattached / framedetached / framenavigatediframe lifecycle
load / domcontentloadedstandard browser events
closepage closes
crashpage process dies
worker / websocketworker / WS opened

On context

On browser

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 isaccept() / dismiss() effect
alertOne-button noticeaccept = OK click. dismiss = also closes (no Cancel).
confirmOK / Cancelaccept = OK. dismiss = Cancel.
promptText inputaccept(text) = OK with value. dismiss = Cancel.
beforeunloadBrowser's "Leave site?" dialogaccept = stay (default). dismiss = leave. Triggered by onbeforeunload.
One-time vs persistentpage.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":

  1. window.open(url) — JS-initiated
  2. <a target="_blank"> — anchor click opens new tab
  3. 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');
});
Why this worksThe 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();
});
Real OAuth gotchaGoogle detects automation and may block. For tests, prefer: (1) Google's test-mode service accounts, (2) intercept the OAuth callback and inject a test token via 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

Module 22 — Popups/Dialogs Q&A bank

What dialog types exist and what do accept/dismiss do?
alert (OK only), confirm (OK/Cancel), prompt (text + OK/Cancel), beforeunload (Stay/Leave). accept = OK / Leave; dismiss = Cancel / Stay. For prompt, accept takes a value.
How do you handle a dialog?
Register 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?
Use the promise-first pattern: 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?
Capture the new Page via 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')?
popup listens only on this Page. page listens on the whole context and catches any new Page (popups, JS-opened tabs, anything). Use context when you're not sure which Page will open it.
How do you handle beforeunload?
Same dialog handler — register 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?
Each test gets a fresh page in a fresh context (Playwright default), so listeners don't persist across tests. Inside a test, prefer once over on when only one dialog is expected.
What happens if no dialog handler is attached?
Playwright auto-dismisses the dialog (acts as if user clicked Cancel) to prevent the test hanging. This is fine for accidental dialogs but bad if your test cares about the outcome — explicit handler always preferred.
How do you test an OAuth popup flow?
Open the popup via promise-first, drive the auth UI in popup, wait for it to close, then assert on the original page reaching the post-auth URL. For production tests, prefer bypassing OAuth via storageState or intercepting the callback rather than driving the provider UI (Google blocks automation).
What is window.opener?
A reference from a popup back to the page that opened it. Used for the popup to send messages or call functions on the opener. Cross-origin restricts what you can do. Modern best practice: open popups with rel="noopener" for security and use postMessage explicitly.
How do you assert that a popup sent a postMessage to its opener?
On the opener Page, expose a function via 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?
Listen with a counter inside the handler, or call context.waitForEvent('page') twice (once for each). For three or more, switch to context.on('page', handler) for the duration of the action.