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 . Los atributos name, id, src y class son todos targets válidos para frameLocator().

iFrames anidados

Los widgets de pago y embeds de terceros a veces anidan iFrames: un iFrame externo que contiene uno interno. frameLocator() soporta el encadenamiento directamente.

test('interactúa con un iFrame anidado', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/checkout');

  // iFrame externo
  const outerFrame = page.frameLocator('iframe#payment-container');

  // iFrame interno anidado dentro del externo
  const innerFrame = outerFrame.frameLocator('iframe#card-number-frame');

  await innerFrame.getByPlaceholder('Número de tarjeta').fill('4111111111111111');

  // De vuelta al iFrame externo para vencimiento y CVV (diferentes iFrames internos)
  const expiryFrame = outerFrame.frameLocator('iframe#expiry-frame');
  await expiryFrame.getByPlaceholder('MM / AA').fill('12/28');

  const cvvFrame = outerFrame.frameLocator('iframe#cvv-frame');
  await cvvFrame.getByPlaceholder('CVV').fill('123');
});

Stripe Elements es el ejemplo real clásico de este patrón: cada campo de input (número de tarjeta, vencimiento, CVV) vive en su propio iFrame anidado separado por razones de cumplimiento PCI. Cada uno necesita su propia cadena de frameLocator.

Los iFrames de origen cruzado (iFrames que sirven contenido de un dominio diferente al de la página host) son completamente soportados por Playwright sin ninguna configuración especial. Esta es un área donde Playwright maneja lo que Selenium históricamente tenía dificultades.

Shadow DOM

Shadow DOM no son iFrames, pero causa el mismo síntoma: los locators estándar no encuentran los elementos. Shadow DOM es una función del navegador que encapsula el DOM interno de un componente. Los web components, elementos personalizados y algunas librerías de UI lo usan.

La buena noticia: los locators de Playwright atraviesan el Shadow DOM por defecto. page.getByRole(), page.getByText() y page.locator() buscan a través de shadow roots sin ninguna configuración adicional.

test('localiza elementos dentro del Shadow DOM', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/components');

  // Esto funciona aunque el botón esté dentro de un shadow root
  await page.getByRole('button', { name: 'Enviar' }).click();

  // Los selectores CSS necesitan el combinador >>> para atravesar el Shadow DOM
  await page.locator('custom-login-form >>> input[type="email"]').fill('usuario@ejemplo.com');
});

Usá >>> en los selectores CSS cuando necesitás atravesar el Shadow DOM explícitamente. Para la mayoría de los casos, prefiere los locators semánticos (getByRole, getByLabel). Atraviesan el Shadow DOM automáticamente y no requieren conocer la estructura interna.

Donde el Shadow DOM se vuelve genuinamente difícil es cuando se combina con iFrames: un shadow root dentro de un iFrame dentro de otro iFrame. En esos casos, encadenas frameLocator() para llegar al documento correcto primero, luego usas locators semánticos que auto-atraviesan el shadow root.

Errores comunes

Cambiar de pestaña antes de que cargue

El bug más frecuente: obtienes la referencia de la nueva página desde waitForEvent('page') e inmediatamente intentas hacer clic en algo. La página todavía está en blanco. Siempre llama waitForLoadState() antes de interactuar.

// Mal: compite con la navegación
const newPage = await newPagePromise;
await newPage.getByRole('button', { name: 'Aceptar' }).click(); // Puede fallar

// Correcto
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
await newPage.getByRole('button', { name: 'Aceptar' }).click();

Perder la referencia a la página original Después de trabajar con una nueva pestaña, los tests a veces intentan asertar en la página original pero usan la variable incorrecta. Nombrá tus referencias de página de forma clara y consistente.

test('mantiene la referencia a la página original', async ({ page, context }) => {
  const originalPage = page; // Renombrar para mayor claridad al trabajar con múltiples pestañas

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

  const termsPage = await newPagePromise;
  await termsPage.waitForLoadState('load');

  // Asertar en la página de términos
  await expect(termsPage.getByRole('heading', { name: 'Términos de servicio' })).toBeVisible();
  await termsPage.close();

  // De vuelta al original. Usá explícitamente originalPage, no page.
  await expect(originalPage.getByRole('heading', { name: 'Registrarse' })).toBeVisible();
});

Usar page.frames() cuando frameLocator() está disponible

La API más antigua page.frames() devuelve un array de objetos Frame. Funciona pero te obliga a gestionar índices o nombres de frames manualmente. frameLocator() es encadenable, type-safe y funciona bien con la auto-espera de Playwright. Usa frameLocator() por defecto a menos que tengas una razón específica para usar la API de frame directamente.

No manejar el timing de carga del iFrame. Los iFrames cargan asincrónicamente. Si el contenido del iFrame no cargó todavía cuando corre tu locator, el elemento no se encontrará. frameLocator() incluye auto-espera (Playwright reintenta el locator hasta encontrar una coincidencia o que expire el timeout), así que esto se maneja automáticamente en la mayoría de los casos. Si estás llamando frame() directamente, necesitas esperar el estado de carga del frame tú mismo.

FAQ

¿Puedo cerrar una pestaña específica sin terminar el test?

Sí. Llamá await newPage.close() para cerrar cualquier objeto Page. La página original y el contexto permanecen abiertos y utilizables.

¿Cómo obtengo una lista de todas las pestañas abiertas en el contexto actual?

Usa context.pages(), que devuelve un array de todos los objetos Page abiertos. El primer elemento es típicamente la primera pestaña abierta en ese contexto.

¿Qué pasa si un iFrame está dentro de otro iFrame? ¿Puedo seguir usando frameLocator?

Sí. Encadenas llamadas a frameLocator(): page.frameLocator('iframe#outer').frameLocator('iframe#inner'). Cada nivel reduce el scope al documento anidado.

¿waitForEvent('page') funciona para popups abiertos por JavaScript (no por clics en links)?

Sí. Cualquier llamada a window.open() en el navegador crea un nuevo evento Page en el contexto, independientemente de si fue disparado por un clic en un link o por JavaScript.

Mi selector de iFrame funciona en DevTools pero no en Playwright. ¿Qué está mal?

Verifica que el iFrame existe en el DOM en el momento en que corre tu locator. Si se inyecta dinámicamente, agrega un page.waitForSelector('iframe#mi-frame') antes de usar frameLocator(). También verifica que estés seleccionando el elemento iFrame en sí mismo, no algo dentro de él. frameLocator() toma el selector de la etiqueta .

¿Puede Playwright interactuar con iFrames de origen cruzado (dominio diferente)?

Sí, esta es una de las fortalezas de Playwright sobre las herramientas basadas en WebDriver más antiguas. Los iFrames de origen cruzado funcionan con frameLocator() sin ninguna configuración especial ni flags de permisos.

→ See also: Interceptación de Red, Mocks y Stubs en Playwright | Page Object Model en Playwright: De Caótico a Mantenible | Locators de Playwright: getByRole, getByLabel, getByText, getByTestId Comparados | Estrategias de Espera en Playwright: Sin Más sleep()