Когда клик открывает новую вкладку, context.waitForEvent('page') нужно настроить как промис до клика, а не после: если новая вкладка откроется быстрее чем зарегистрируется слушатель, событие пропадёт и вызов зависнет навсегда. Получить новый объект Page ещё не значит что он загружен: он создаётся пустым и затем навигирует, поэтому перед поиском элементов нужен waitForLoadState(). Эта статья разбирает полные паттерны для новых вкладок, всплывающих окон, frameLocator() для iFrame включая вложенные Stripe-подобные фреймы где каждое поле карты живёт в отдельном iFrame, пробивание shadow DOM, и соглашение об именовании переменных которое не даёт запутаться какая страница какая.
Как Playwright моделирует страницы, контексты и вкладки
Прежде чем писать код, нужно понять иерархию объектов в Playwright.
Browser: процесс браузера. BrowserContext: изолированная сессия внутри браузера со своими куками, хранилищем и состоянием сети. Page: одна вкладка или окно внутри контекста. Когда пользователь кликает ссылку открывающую новую вкладку, Playwright видит это как новый объект Page добавляемый в существующий BrowserContext.
import { chromium } from '@playwright/test';
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage(); // Вкладка 1
const page2 = await context.newPage(); // Вкладка 2 в той же сессииЭто важно по нескольким причинам. Страницы в одном контексте разделяют куки и localStorage: если page 1 залогинена, page 2 тоже залогинена. Страницы в разных контекстах полностью изолированы, именно поэтому Playwright использует отдельные контексты для симуляции разных пользователей. Для перехвата событий во всех вкладках (не только в одной) используй context.on() и context.route(), а не page.on() и page.route().
С этой моделью в голове всё остальное становится понятнее.
Обнаружение новой вкладки через context.waitForEvent('page')
Самый частый сценарий: пользователь кликает ссылку или кнопку которая открывает что-то в новой вкладке. Тест должен получить ссылку на эту вкладку и взаимодействовать с ней.
Правильный паттерн: настроить слушатель до того как запустить действие открывающее новую вкладку. Если запустить действие первым, событие новой страницы может сработать до того как слушатель будет на месте.
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');
// Настраиваем слушатель ДО клика
const newPagePromise = context.waitForEvent('page');
// Этот клик открывает новую вкладку
await page.getByRole('link', { name: 'Open in new tab' }).click();
// Ждём новую страницу и даём ей загрузиться
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
// Взаимодействуем с новой вкладкой
await expect(newPage).toHaveURL(/\/docs/);
await expect(newPage.getByRole('heading', { level: 1 })).toBeVisible();
// Оригинальная страница по-прежнему доступна
await expect(page).toHaveURL('https://lab.becomeqa.com');
});context.waitForEvent('page') возвращает промис который резолвится в новый объект Page как только он создаётся. «Создаётся» не значит «загружается»: страница существует, но может ещё навигировать. Всегда добавляй waitForLoadState() перед поиском элементов.
await page.click(...) и потом await context.waitForEvent('page'). Если новая вкладка открывается достаточно быстро, событие срабатывает между этими двумя строками и waitForEvent зависает навсегда. Всегда настраивай промис первым, потом запускай действие.Открытие новой вкладки программно
Иногда нужно полностью контролировать новую вкладку из теста: открыть конкретный URL в новой вкладке рядом с основной страницей, или настроить вторую сессию для симуляции второго пользователя. Используй context.newPage() напрямую.
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');
// Обе страницы в одной залогиненной сессии
await expect(page1.getByText('Welcome back')).toBeVisible();
await expect(page2.getByRole('heading', { name: 'Account Settings' })).toBeVisible();
// Переключаем фокус на page1 (важно для некоторых браузерных поведений)
await page1.bringToFront();
await page1.getByRole('button', { name: 'New Trip' }).click();
});bringToFront() делает страницу активной вкладкой в браузере. В headless-режиме это редко влияет на выполнение, но некоторые поведения зависящие от фокуса (drag-and-drop, отдельные keyboard-события) требуют его.
Обработка всплывающих окон
Попапы (окна открытые через window.open()) следуют тому же паттерну что и вкладки. В модели Playwright это просто новые объекты Page. Подход с waitForEvent('page') работает идентично.
test('handles an OAuth popup window', async ({ page, context }) => {
await page.goto('https://lab.becomeqa.com/login');
// Слушаем попап до клика
const popupPromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Login with Google' }).click();
const popup = await popupPromise;
await popup.waitForLoadState('networkidle');
// Взаимодействуем с OAuth-попапом
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();
// После завершения OAuth попап закрывается и основная страница обновляется
await popup.waitForEvent('close');
await expect(page).toHaveURL(/\/dashboard/);
});Если попап закрывается автоматически после завершения флоу (как обычно делают OAuth-окна), можно подождать событие close на объекте попапа перед ассёртами на основной странице.
Ожидание навигации после открытия новой вкладки
Тонкая вариация паттерна с новой вкладкой: ссылки с target="_blank" открывают новую вкладку и сразу навигируют на URL. Новый объект Page создаётся пустым, затем навигирует. Это может вызвать гонку состояний если пытаться делать ассёрты до завершения навигации.
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;
// Ждём конкретную навигацию, а не просто DOMContentLoaded
await newPage.waitForLoadState('load');
// Теперь безопасно делать ассёрты на URL и контент
await expect(newPage).toHaveURL(/\/docs\//);
await expect(newPage.getByRole('navigation')).toBeVisible();
});waitForLoadState('load') нужен когда важно чтобы все ресурсы (картинки, скрипты) загрузились. waitForLoadState('domcontentloaded') быстрее когда важен только HTML. waitForLoadState('networkidle') подходит когда страница делает дополнительные XHR после загрузки, но работает медленнее и иногда флакует на нагруженных страницах.
Когда точный URL новой вкладки известен заранее, waitForURL() точнее:
const newPage = await newPagePromise;
await newPage.waitForURL('**/docs/getting-started');
await expect(newPage.getByRole('heading', { name: 'Getting Started' })).toBeVisible();iFrame: почему это неудобно и как frameLocator решает проблему
iFrame встраивается в основную страницу как отдельный документ. С точки зрения браузера у него свой DOM, свой JavaScript-контекст и своё происхождение. Стандартные локаторы (page.getByRole(), page.getByText()) ищут только в DOM основной страницы. Внутрь iFrame они не заглядывают.
До появления frameLocator() решение было громоздким: получить объект фрейма, потом вызывать методы локатора на нём отдельно.
// Старый способ: работает, но многословно
const frame = page.frame({ name: 'payment-widget' });
await frame?.getByLabel('Card Number').fill('4111111111111111');frameLocator() чище. Возвращает локатор ограниченный содержимым iFrame, что позволяет цеплять локаторы точно так же как на основной странице.
test('fills out a payment form inside an iFrame', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/checkout');
// Находим iFrame по его селектору, потом цепляем локаторы внутри
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');
// Кнопка Submit находится на основной странице
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Payment successful')).toBeVisible();
});В frameLocator() можно использовать любой валидный CSS-селектор: iframe#checkout-frame, iframe[src*="stripe.com"], iframe.payment-container. Если на странице несколько iFrame, будь достаточно конкретным чтобы попасть ровно в один.
. Для frameLocator() подходят атрибуты name, id, src и class.Вложенные iFrame
Платёжные виджеты и сторонние эмбеды иногда вкладывают iFrame в iFrame: внешний содержит внутренний. frameLocator() поддерживает прямое цепление.
test('interacts with a nested iFrame', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/checkout');
// Внешний iFrame
const outerFrame = page.frameLocator('iframe#payment-container');
// Внутренний iFrame вложенный во внешний
const innerFrame = outerFrame.frameLocator('iframe#card-number-frame');
await innerFrame.getByPlaceholder('Card number').fill('4111111111111111');
// Возвращаемся во внешний iFrame для expiry и CVV (разные внутренние фреймы)
const expiryFrame = outerFrame.frameLocator('iframe#expiry-frame');
await expiryFrame.getByPlaceholder('MM / YY').fill('12/28');
const cvvFrame = outerFrame.frameLocator('iframe#cvv-frame');
await cvvFrame.getByPlaceholder('CVV').fill('123');
});Самый известный пример из реального мира: Stripe Elements. Каждое поле ввода (номер карты, срок, CVV) живёт в своём отдельном вложенном iFrame из соображений PCI-совместимости. Каждое требует собственной цепочки frameLocator.
Shadow DOM
Shadow DOM не то же что iFrame, но симптом тот же: стандартные локаторы не находят элементы. Это браузерная функция инкапсулирующая внутренний DOM компонента. Веб-компоненты, кастомные элементы и некоторые UI-библиотеки используют её.
Хорошая новость: локаторы Playwright пробивают shadow DOM по умолчанию. page.getByRole(), page.getByText() и page.locator() ищут через shadow root без дополнительной конфигурации.
test('locates elements inside shadow DOM', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/components');
// Работает даже если кнопка внутри shadow root
await page.getByRole('button', { name: 'Submit' }).click();
// CSS-селекторы нужен комбинатор >>> чтобы пробить shadow DOM
await page.locator('custom-login-form >>> input[type="email"]').fill('user@example.com');
});Используй >>> в CSS-селекторах когда нужно явно пробить shadow DOM. В большинстве случаев предпочитай семантические локаторы (getByRole, getByLabel): они автоматически пробивают shadow DOM и не требуют знания внутренней структуры.
Настоящие сложности начинаются когда shadow DOM комбинируется с iFrame: shadow root внутри iFrame внутри другого iFrame. В таких случаях цепляй frameLocator() чтобы добраться до нужного документа, а потом используй семантические локаторы которые автоматически пробивают shadow root.
Частые ошибки
Переключение на вкладку до загрузки. Самый частый баг: получаешь ссылку на новую страницу черезwaitForEvent('page') и сразу пытаешься кликнуть что-то. Страница ещё пустая. Всегда вызывай waitForLoadState() перед взаимодействием.
// Неверно: гонка с навигацией
const newPage = await newPagePromise;
await newPage.getByRole('button', { name: 'Accept' }).click(); // Может упасть
// Верно
const newPage = await newPagePromise;
await newPage.waitForLoadState('domcontentloaded');
await newPage.getByRole('button', { name: 'Accept' }).click();test('loses track of the original page', async ({ page, context }) => {
const originalPage = page; // Переименовываем для ясности при работе с несколькими вкладками
const newPagePromise = context.waitForEvent('page');
await originalPage.getByRole('link', { name: 'Terms' }).click();
const termsPage = await newPagePromise;
await termsPage.waitForLoadState('load');
// Ассёрты на странице с условиями
await expect(termsPage.getByRole('heading', { name: 'Terms of Service' })).toBeVisible();
await termsPage.close();
// Возвращаемся к оригинальной. Явно используй originalPage, не page.
await expect(originalPage.getByRole('heading', { name: 'Sign Up' })).toBeVisible();
});page.frames() когда доступен frameLocator(). Старый API page.frames() возвращает массив объектов Frame. Работает, но заставляет управлять индексами или именами фреймов вручную. frameLocator() поддерживает цепление, типобезопасен и корректно работает с автоожиданием Playwright. Используй frameLocator() по умолчанию если нет конкретной причины использовать frame API напрямую.
Игнорирование тайминга загрузки iFrame. iFrame загружается асинхронно. Если содержимое ещё не загрузилось когда запускается локатор, элемент не найдётся. frameLocator() включает автоожидание (Playwright повторяет попытку пока не найдёт совпадение или не истечёт таймаут), поэтому в большинстве случаев это обрабатывается автоматически. При прямом использовании frame() нужно самостоятельно ждать загрузки фрейма.
FAQ
Можно закрыть конкретную вкладку не завершая тест?
Да. Вызови await newPage.close() для любого объекта Page. Оригинальная страница и контекст остаются открытыми и доступными.
Как получить список всех открытых вкладок в текущем контексте?
Используй context.pages(): возвращает массив всех открытых объектов Page. Первый элемент обычно первая вкладка открытая в этом контексте.
Если iFrame вложен в другой iFrame, можно ли использовать frameLocator?
Да. Цепляй вызовы frameLocator(): page.frameLocator('iframe#outer').frameLocator('iframe#inner'). Каждый уровень сужает область до вложенного документа.
Работает ли waitForEvent('page') для попапов открытых через JavaScript (не клик по ссылке)?
Да. Любой вызов window.open() в браузере создаёт событие нового Page на контексте, независимо от того был ли он вызван кликом по ссылке или JavaScript.
Селектор iFrame работает в DevTools но не в Playwright. В чём проблема?
Убедись что iFrame существует в DOM в момент запуска локатора. Если он добавляется динамически, добавь page.waitForSelector('iframe#my-frame') перед использованием frameLocator(). Также проверь что выбираешь сам элемент iFrame, а не что-то внутри него: frameLocator() принимает селектор тега .
Может ли Playwright взаимодействовать с cross-origin iFrame (другой домен)?
Да, это одна из сильных сторон Playwright по сравнению со старыми WebDriver-инструментами. Cross-origin iFrame работают с frameLocator() без специальной конфигурации.