New Tabs, Popups, and Dialogs in Playwright

Tabs are pages, not windows

Playwright models every tab and popup as a Page inside a BrowserContext. There is no active window, no focus stack, and no switchTo().window() like Selenium. When a second tab opens, you receive a second Page object, and both stay fully usable at the same time. You interact with whichever one you hold a reference to, so "switching tabs" just means calling methods on a different variable.

This design removes the bookkeeping that makes multi-window Selenium tests brittle, but it introduces one pattern you have to get right: catching the new page when it opens.

Catching a tab opened by target=_blank

When a link with target="_blank" opens a new tab, the browser context fires a page event. The reliable way to catch it is to start waiting before you click:

test('opens the docs in a new tab', async ({ page, context }) => {
  const pagePromise = context.waitForEvent('page');
  await page.getByRole('link', { name: 'API documentation' }).click();
  const newPage = await pagePromise;

  await newPage.waitForLoadState();
  await expect(newPage.getByRole('heading', { name: 'API reference' })).toBeVisible();
});

The ordering matters, and getting it wrong is the classic mistake with this API:

// Anti-pattern: the tab opens before anyone is listening
await page.getByRole('link', { name: 'API documentation' }).click();
const newPage = await context.waitForEvent('page'); // may wait forever

If the tab opens during the click, the event fires while nobody listens, and waitForEvent then waits for a next event that never comes. Creating the promise first, without awaiting it, registers the listener ahead of the click. Then the await afterwards collects the result whether the event fired immediately or a moment later.

Note that pagePromise resolves as soon as the tab exists, which can be before it has loaded anything. Call newPage.waitForLoadState() before asserting, or rely on a web-first assertion that retries until the content shows up.

Popups from window.open

When the page itself calls window.open(), the opener page fires a popup event. The pattern is identical, just on page instead of context:

const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open preview' }).click();
const popup = await popupPromise;

await expect(popup.getByRole('heading', { name: 'Preview' })).toBeVisible();

Every popup is also a new page in the context, so context.waitForEvent('page') would catch it too. Prefer popup when the opener is known, because it scopes the wait to that page and will not accidentally grab an unrelated tab.

Driving two pages at once

Both pages remain live after the split. Locators on each one are independent, and you can interleave actions freely:

await newPage.getByRole('button', { name: 'Copy API key' }).click();
await page.getByLabel('API key').fill(await page.evaluate(() => navigator.clipboard.readText()));

You will find page.bringToFront() in the API, but you almost never need it. Playwright drives background tabs at full fidelity: clicks, fills, and assertions work whether or not the tab is visually in front. Bring a page to the front only when something depends on actual visibility, such as capturing a video of the right tab or testing code that listens for visibility changes.

Closing pages and returning to the original

Close a tab you are done with by calling close() on it. The original page needs no reactivation, because you never left it:

await newPage.close();
await page.getByRole('button', { name: 'Continue' }).click();

If you need to react to a tab that closes on its own, page.waitForEvent('close') resolves when it goes away. That is common with popups that close themselves after finishing a job, which is exactly how OAuth flows behave.

An OAuth popup login, end to end

Single sign-on buttons usually open the identity provider in a popup, let the user authenticate, then close the popup and log the opener in. All the pieces above combine into one flow:

test('signs in through the SSO popup', async ({ page }) => {
  await page.goto('/login');

  const popupPromise = page.waitForEvent('popup');
  await page.getByRole('button', { name: 'Sign in with AcmeID' }).click();
  const popup = await popupPromise;

  await popup.getByLabel('Email').fill('user@example.com');
  await popup.getByLabel('Password').fill(process.env.SSO_PASSWORD ?? '');
  await popup.getByRole('button', { name: 'Authorize' }).click();

  await popup.waitForEvent('close');
  await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
});

The test waits for the popup, authenticates inside it, waits for it to close itself, and then asserts the logged-in state on the original page. Against real third-party providers like Google, automated logins tend to hit bot detection, so run this pattern against a test identity provider or a staging realm. For everything else around sessions and storageState, see the login and authentication guide.

Native dialogs: alert, confirm, and prompt

alert(), confirm(), and prompt() open browser-native dialogs that no locator can reach, because they are not part of the page's DOM. Playwright handles them through the dialog event:

page.on('dialog', async (dialog) => {
  console.log(`${dialog.type()}: ${dialog.message()}`);
  await dialog.accept();
});

await page.getByRole('button', { name: 'Delete project' }).click();
await expect(page.getByText('Project deleted')).toBeVisible();

Accepting a confirm makes it return true to the page, dismissing returns false. For prompt, pass the reply as an argument: dialog.accept('my answer').

By default, with no handler registered, Playwright auto-dismisses every dialog. That default exists so an unexpected alert cannot freeze a test run, but it surprises people in one specific way: a delete flow guarded by confirm() silently does nothing, because the auto-dismissed confirm returned false. If a click seems to have no effect, a swallowed confirm dialog is worth checking before you suspect your locator. As soon as any dialog listener is registered, auto-dismiss turns off and your handler must call accept() or dismiss(), otherwise actions that trigger dialogs will hang waiting for a verdict.

One-time vs persistent handlers

page.on('dialog') stays registered for the life of the page and handles every dialog the same way. That is fine for a blanket accept, but when different dialogs need different answers, or when you want to assert the message of one specific dialog, use page.once('dialog'), which unregisters itself after the first event:

page.once('dialog', async (dialog) => {
  expect(dialog.message()).toBe('Delete this monitor? This cannot be undone.');
  await dialog.dismiss();
});
await page.getByRole('button', { name: 'Delete monitor' }).click();

await expect(page.getByText('Monitor deleted')).toBeHidden();

After the once handler runs, later dialogs fall back to whatever other handlers exist, or to auto-dismiss if there are none. That makes once the safer default in tests: it answers exactly the dialog you provoked and leaves no persistent behavior behind to confuse the next assertion.

beforeunload dialogs

Pages with unsaved changes often register a beforeunload handler so the browser asks before leaving. Playwright skips these by default: page.close() just closes. To exercise the dialog, opt in:

page.on('dialog', async (dialog) => {
  if (dialog.type() === 'beforeunload') {
    await dialog.accept();
  }
});
await page.close({ runBeforeUnload: true });

Accepting lets the page unload, dismissing keeps it open, which is how you test that the warning actually protects unsaved work. Note that with runBeforeUnload: true the close is no longer guaranteed, since the dialog can be dismissed, so pair it with an explicit handler like the one above.

Printing dialog.message() for debugging

When a test hangs or a flow dies silently, log what dialogs fire before deciding anything else:

page.on('dialog', async (dialog) => {
  console.log(`dialog ${dialog.type()}: "${dialog.message()}"`);
  await dialog.dismiss();
});

Many mysterious failures turn out to be an error alert you did not know the app could raise, and the message text tells you immediately what went wrong. The waits and timeouts guide covers the other usual suspects when a test stalls.

Run it in production

Popup logins and multi-tab flows are the parts of an app most likely to break when a provider updates its pages, and your CI will not see that happen. Hyperping Browser Checks run the same Playwright scripts on a schedule against production and alert you when the flow stops completing.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.