Когда клик открывает новую вкладку, 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, будь достаточно конкретным чтобы попасть ровно в один.

Если не знаешь какой селектор использовать для iFrame, открой DevTools и инспектируй элемент