Cuando un clic abre una nueva pestaña, context.waitForEvent('page') debe configurarse como una promesa antes del clic, no después: si la nueva pestaña se abre más rápido de lo que el listener se registra, el evento se pierde y la llamada espera para siempre. Obtener el nuevo objeto Page tampoco significa que esté cargado: se crea vacío y luego navega, así que waitForLoadState() es necesario antes de que cualquier locator encuentre elementos. Este artículo cubre los patrones completos para nuevas pestañas, ventanas popup, frameLocator() para iFrames incluyendo frames anidados al estilo Stripe donde cada campo de tarjeta vive en su propio iFrame, piercing de Shadow DOM, y la disciplina de nomenclatura que evita perder el rastro de qué variable de página es cuál.

Cómo modela Playwright páginas, contextos y pestañas

Antes de escribir una sola línea de código, necesitas entender la jerarquía de objetos que usa Playwright.

Un Browser es el proceso del navegador. Un BrowserContext es una sesión aislada dentro de ese navegador, con sus propias cookies, almacenamiento y estado de red. Un Page representa una sola pestaña o ventana dentro de un contexto. Cuando un usuario hace clic en un link que abre una nueva pestaña, Playwright lo ve como un nuevo Page que se agrega al BrowserContext existente.

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

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

const page2 = await context.newPage(); // Pestaña 2 en la misma sesión

Este modelo importa porque las páginas en el mismo contexto comparten cookies y local storage (si la página 1 tiene sesión iniciada, la página 2 también), las páginas en contextos diferentes están completamente aisladas (por eso Playwright usa contextos separados para simular múltiples usuarios), y cuando necesitas interceptar eventos en todas las pestañas, usa context.on() y context.route(), no page.on() y page.route().

Detectar una nueva pestaña con context.waitForEvent('page')

El escenario más común: un usuario hace clic en un link o botón que abre algo en una nueva pestaña. Tu test necesita obtener una referencia a esa nueva pestaña e interactuar con ella.

El patrón correcto es configurar el listener antes de disparar la acción que causa que se abra la nueva pestaña. Si disparas la acción primero, el evento de nueva página puede ocurrir antes de que tu listener esté en su lugar.

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

test('maneja una nueva pestaña abierta por un clic en un link', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com');

  // Configurar el listener ANTES de hacer clic
  const newPagePromise = context.waitForEvent('page');

  // Este clic abre una nueva pestaña
  await page.getByRole('link', { name: 'Abrir en nueva pestaña' }).click();

  // Esperar la nueva página y dejarla cargar
  const newPage = await newPagePromise;
  await newPage.waitForLoadState('domcontentloaded');

  // Ahora interactuar con la nueva pestaña
  await expect(newPage).toHaveURL(/\/docs/);
  await expect(newPage.getByRole('heading', { level: 1 })).toBeVisible();

  // La página original sigue accesible
  await expect(page).toHaveURL('https://lab.becomeqa.com');
});

context.waitForEvent('page') devuelve una promesa que se resuelve con el nuevo objeto Page en cuanto se crea. "Creado" no significa "cargado". La página existe pero puede seguir navegando. Siempre sigue con waitForLoadState() antes de intentar encontrar elementos.
Un error común es esperar el clic antes de configurar el listener: await page.click(...) y luego await context.waitForEvent('page'). Si la nueva pestaña se abre lo suficientemente rápido, el evento se dispara entre esas dos líneas y waitForEvent espera para siempre. Configurá siempre la promesa primero, luego dispará la acción.

Abrir una nueva pestaña de forma programática

A veces quieres controlar completamente la nueva pestaña desde tu test: abrir una URL específica en una nueva pestaña junto a la página principal, o configurar una segunda sesión para simular un segundo usuario. Usa context.newPage() directamente.

test('dos pestañas en la misma sesión', 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');

  // Ambas páginas están en la misma sesión autenticada
  await expect(page1.getByText('Bienvenido de vuelta')).toBeVisible();
  await expect(page2.getByRole('heading', { name: 'Configuración de cuenta' })).toBeVisible();

  // Traer page1 de vuelta al foco (importa para algunos comportamientos específicos del navegador)
  await page1.bringToFront();
  await page1.getByRole('button', { name: 'Nuevo viaje' }).click();
});

bringToFront() hace que la página sea la pestaña activa en la interfaz del navegador. Raramente afecta la ejecución de tests en headless, pero algunos comportamientos dependientes del foco (drag-and-drop, ciertos eventos de teclado) lo requieren.

Manejar ventanas popup

Los popups (ventanas abiertas con window.open()) siguen exactamente el mismo patrón que las pestañas. En el modelo de Playwright, son simplemente nuevos objetos Page. El enfoque waitForEvent('page') funciona de forma idéntica.

test('maneja una ventana popup de OAuth', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com/login');

  // Escuchar el popup antes de hacer clic
  const popupPromise = context.waitForEvent('page');

  await page.getByRole('button', { name: 'Iniciar sesión con Google' }).click();

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

  // Interactuar con el popup de OAuth
  await popup.getByLabel('Email').fill('test@ejemplo.com');
  await popup.getByRole('button', { name: 'Siguiente' }).click();
  await popup.getByLabel('Contraseña').fill('testpassword');
  await popup.getByRole('button', { name: 'Iniciar sesión' }).click();

  // Después de que OAuth completa, el popup se cierra y la página principal se actualiza
  await popup.waitForEvent('close');
  await expect(page).toHaveURL(/\/dashboard/);
});

Si el popup se cierra automáticamente después de completar su flujo (como suelen hacer las ventanas de OAuth), puedes esperar el evento close en la página popup para confirmar que terminó antes de asertar en la página original.

Una variante sutil del patrón de nueva pestaña: los links con target="_blank" abren una nueva pestaña y navegan inmediatamente a una URL. El nuevo Page se crea vacío, luego navega. Esto puede causar una race condition si intentás asertar antes de que la navegación complete.

test('espera la navegación en una nueva pestaña', async ({ page, context }) => {
  await page.goto('https://lab.becomeqa.com');

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

  const newPage = await newPagePromise;

  // Esperar que la navegación específica complete, no solo DOMContentLoaded
  await newPage.waitForLoadState('load');

  // Ahora es seguro asertar sobre la URL y el contenido
  await expect(newPage).toHaveURL(/\/docs\//);
  await expect(newPage.getByRole('navigation')).toBeVisible();
});

Usá waitForLoadState('load') cuando necesitás que todos los recursos (imágenes, scripts) terminen. Usá waitForLoadState('domcontentloaded') para verificaciones más rápidas cuando solo te importa el HTML. Usá waitForLoadState('networkidle') cuando la página dispara peticiones XHR adicionales después de cargar, aunque este es más lento y ocasionalmente inestable en páginas ocupadas.

Para casos donde conocés la URL exacta a la que va a navegar la nueva pestaña, waitForURL() es más preciso:

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

iFrames: por qué son problemáticos y cómo los soluciona frameLocator

Un iFrame es un documento separado embebido dentro de la página principal. Desde la perspectiva del navegador, tiene su propio DOM, su propio contexto de JavaScript y su propio origen de seguridad. Los locators estándar (page.getByRole(), page.getByText()) solo buscan en el DOM de la página principal. No pueden ver dentro de los iFrames.

Antes de que Playwright introdujera frameLocator(), el workaround era engorroso: obtener el objeto frame y luego llamar métodos de locator en él por separado.

// Forma antigua: funciona pero es verbosa
const frame = page.frame({ name: 'payment-widget' });
await frame?.getByLabel('Número de tarjeta').fill('4111111111111111');

frameLocator() es más limpio. Devuelve un locator acotado al contenido del iFrame, para que puedas encadenar locators exactamente igual que lo harías en la página principal.

test('completa un formulario de pago dentro de un iFrame', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/checkout');

  // Localizar el iFrame por su selector, luego encadenar locators dentro de él
  const paymentFrame = page.frameLocator('iframe[name="payment-widget"]');

  await paymentFrame.getByLabel('Número de tarjeta').fill('4111111111111111');
  await paymentFrame.getByLabel('Fecha de vencimiento').fill('12/28');
  await paymentFrame.getByLabel('CVV').fill('123');

  // De vuelta en la página principal para el botón de submit
  await page.getByRole('button', { name: 'Pagar ahora' }).click();

  await expect(page.getByText('Pago exitoso')).toBeVisible();
});

Podés usar cualquier selector CSS válido en frameLocator(): iframe#checkout-frame, iframe[src*="stripe.com"], iframe.payment-container. Si la página tiene múltiples iFrames, sé lo suficientemente específico para que coincida exactamente con uno.

Si no sabés qué selector usar para un iFrame, abre DevTools del navegador e inspecciona el elemento