When a click opens a new tab, context.waitForEvent('page') must be set up as a promise before the click, not after: if the new tab opens faster than the listener registers, the event is gone and the call waits forever. Getting the new Page object also doesn't mean it's loaded — it's created empty and then navigates, so waitForLoadState() is required before any locators will find elements. This article covers the complete patterns for new tabs, popup windows, frameLocator() for iFrames including nested Stripe-style frames where each card field lives in its own iFrame, shadow DOM piercing, and the naming discipline that prevents losing track of which page variable is which.

How Playwright models pages, contexts, and tabs

Before writing a single line of code, you need to understand the object hierarchy Playwright uses.

A Browser is the browser process. A BrowserContext is an isolated session within that browser, with its own cookies, storage, and network state. A Page represents a single tab or window within a context. When a user clicks a link that opens a new tab, Playwright sees that as a new Page being added to the existing BrowserContext.

import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage(); // Tab 1

const page2 = await context.newPage(); // Tab 2 in the same session

This model matters because:

  • Pages in the same context share cookies and local storage. If page 1 is logged in, page 2 is also logged in.
  • Pages in different contexts are completely isolated. This is why Playwright uses separate contexts to simulate multiple users.
  • When you need to intercept events across all tabs (not just one), use context.on() and context.route(), not page.on() and page.route().

With that model in mind, the rest of this article makes much more sense.

Detecting a new tab with context.waitForEvent('page')

The most common scenario: a user clicks a link or button that opens something in a new tab. Your test needs to get a reference to that new tab and interact with it.

The correct pattern is to set up the listener before triggering the action that causes the new tab to open. If you trigger the action first, the new page event might fire before your listener is in place.

import { test, expect } from '@playwright/test';

test('handles a new tab opened by a link click', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com');

  // Set up the listener BEFORE clicking
  const newPagePromise = context.waitForEvent('page');

  // This click opens a new tab
  await page.getByRole('link', { name: 'Open in new tab' }).click();

  // Wait for the new page and let it load
  const newPage = await newPagePromise;
  await newPage.waitForLoadState('domcontentloaded');

  // Now interact with the new tab
  await expect(newPage).toHaveURL(/\/docs/);
  await expect(newPage.getByRole('heading', { level: 1 })).toBeVisible();

  // The original page is still accessible
  await expect(page).toHaveURL('https://lab.becomeqa.com');
});

context.waitForEvent('page') returns a promise that resolves with the new Page object as soon as it's created. Note that "created" does not mean "loaded". The page exists but might still be navigating. Always follow it with waitForLoadState() before trying to find elements.
A common mistake is awaiting the click before setting up the listener: await page.click(...) then await context.waitForEvent('page'). If the new tab opens fast enough, the event fires between those two lines and waitForEvent will wait forever. Always set up the promise first, then trigger the action.

Opening a new tab programmatically

Sometimes you want to control the new tab entirely from your test: open a specific URL in a new tab alongside the main page, or set up a second session to simulate a second user. Use context.newPage() directly.

test('two tabs in the same session', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  await page1.goto('https://lab.becomeqa.com/dashboard');
  await page2.goto('https://lab.becomeqa.com/settings');

  // Both pages are in the same logged-in session
  await expect(page1.getByText('Welcome back')).toBeVisible();
  await expect(page2.getByRole('heading', { name: 'Account Settings' })).toBeVisible();

  // Bring page1 back into focus (matters for some browser-specific behaviors)
  await page1.bringToFront();
  await page1.getByRole('button', { name: 'New Trip' }).click();
});

bringToFront() makes the page the active tab in the browser UI. This rarely affects headless test execution, but some focus-dependent behaviors (drag-and-drop, certain keyboard events) require it.

Handling popup windows

Popups (windows opened with window.open()) follow the exact same pattern as tabs. In Playwright's model, they're just new Page objects. The waitForEvent('page') approach works identically.

test('handles an OAuth popup window', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com/login');

  // Listen for the popup before clicking
  const popupPromise = context.waitForEvent('page');

  await page.getByRole('button', { name: 'Login with Google' }).click();

  const popup = await popupPromise;
  await popup.waitForLoadState('networkidle');

  // Interact with the OAuth popup
  await popup.getByLabel('Email').fill('test@example.com');
  await popup.getByRole('button', { name: 'Next' }).click();
  await popup.getByLabel('Password').fill('testpassword');
  await popup.getByRole('button', { name: 'Sign in' }).click();

  // After OAuth completes, the popup closes and the main page updates
  await popup.waitForEvent('close');
  await expect(page).toHaveURL(/\/dashboard/);
});

If the popup closes automatically after completing its flow (as OAuth windows typically do), you can wait for the close event on the popup page to confirm it's done before asserting on the original page.

A subtle variation on the new-tab pattern: links with target="_blank" open a new tab and immediately navigate to a URL. The new Page is created empty, then navigates. This can cause a race condition if you try to assert before navigation completes.

test('waits for navigation in a new tab', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com');

  const newPagePromise = context.waitForEvent('page');
  await page.getByRole('link', { name: 'Documentation' }).click();

  const newPage = await newPagePromise;

  // Wait for the specific navigation to complete, not just DOMContentLoaded
  await newPage.waitForLoadState('load');

  // Now it's safe to assert on the URL and content
  await expect(newPage).toHaveURL(/\/docs\//);
  await expect(newPage.getByRole('navigation')).toBeVisible();
});

Use waitForLoadState('load') when you need all resources (images, scripts) to finish. Use waitForLoadState('domcontentloaded') for faster checks when you only care about the HTML. Use waitForLoadState('networkidle') when the page triggers additional XHR requests after load, though this one is slower and occasionally flaky on busy pages.

For cases where you know the exact URL the new tab will navigate to, waitForURL() is more precise:

const newPage = await newPagePromise;
await newPage.waitForURL('**/docs/getting-started');
await expect(newPage.getByRole('heading', { name: 'Getting Started' })).toBeVisible();

iFrames: why they're annoying and how frameLocator fixes them

An iFrame is a separate document embedded inside the main page. From the browser's perspective, it has its own DOM, its own JavaScript context, and its own security origin. Standard locators (page.getByRole(), page.getByText()) only search the main page's DOM. They can't see inside iFrames.

Before Playwright introduced frameLocator(), the workaround was clunky: get the frame object, then call locator methods on it separately.

// Old way: works but verbose
const frame = page.frame({ name: 'payment-widget' });
await frame?.getByLabel('Card Number').fill('4111111111111111');

frameLocator() is cleaner. It returns a locator scoped to the iFrame's content, so you can chain locators exactly the same way you would on the main page.

test('fills out a payment form inside an iFrame', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/checkout');

  // Locate the iFrame by its selector, then chain locators inside it
  const paymentFrame = page.frameLocator('iframe[name="payment-widget"]');

  await paymentFrame.getByLabel('Card Number').fill('4111111111111111');
  await paymentFrame.getByLabel('Expiry Date').fill('12/28');
  await paymentFrame.getByLabel('CVV').fill('123');

  // Back on the main page for the submit button
  await page.getByRole('button', { name: 'Pay Now' }).click();

  await expect(page.getByText('Payment successful')).toBeVisible();
});

You can use any valid CSS selector in frameLocator(): iframe#checkout-frame, iframe[src*="stripe.com"], iframe.payment-container. If the page has multiple iFrames, be specific enough that it matches exactly one.

If you're not sure what selector to use for an iFrame, open the browser DevTools and inspect the