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ónEste 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.
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.
Esperar la navegación después de que un link abre una nueva pestaña
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.
. 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.
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.
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.
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.
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.
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 .
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.