Назад к блогу
Начало работы

Puppeteer: владение BrowserContext и Page и очистка ресурсов

Разберитесь, кто владеет браузерами, контекстами, страницами, всплывающими окнами, тайм-аутами и очисткой в Puppeteer, чтобы разрешённые тесты завершались без утечек ресурсов.

Документация

Нужна структурированная документация по теме Начало работы?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

Процесс браузера с одним изолированным контекстом, его страницами и явными границами очистки

У Browser, BrowserContext и Page разные владельцы

Кратко: в Puppeteer объект Browser владеет соединением с процессом браузера, BrowserContext владеет изолированной группой целей браузера и данных под управлением браузера, а Page представляет одну вкладку или цель страницы внутри контекста. Тестовый код владеет созданными им дескрипторами Puppeteer. Он должен закрывать их в той же области видимости, где получил, если только очисткой явно не владеет фикстура более высокого уровня. Такая модель владения делает сбои понятными: сбой страницы относится к одному рабочему процессу, сбой контекста влияет на соответствующий изолированный сеанс, а сбой браузера влияет на все контексты, использующие тот же процесс.

Метод Puppeteer Browser.createBrowserContext() создаёт контекст, который не разделяет cookie или кеш с другими контекстами браузера. context.newPage() создаёт страницу в этом контексте. В отличие от него, browser.newPage() создаёт страницу в контексте браузера по умолчанию. Это различие легко пропустить: оба вызова возвращают Page, но только первый явно показывает владельца контекста в коде. Тест, которому нужна изоляция отдельных сценариев, должен намеренно создать контекст не по умолчанию и создавать страницы из него.

У контекста по умолчанию особый жизненный цикл. browser.defaultBrowserContext() возвращает его, но документация Puppeteer указывает, что контекст по умолчанию нельзя закрыть. Он прекращает существование вместе с браузером. Контекст, возвращённый createBrowserContext(), можно закрыть независимо, и его закрытие закрывает все связанные страницы. Поэтому контекст по умолчанию удобен для короткого скрипта, но плохо подходит как неявная граница фикстуры в общем процессе тестов. Один тест не может надёжно завершить его, не остановив при этом работу несвязанных тестов.

Эта связь представляет собой дерево ресурсов, а не просто граф объектов. Браузер может содержать несколько контекстов. Контекст может содержать несколько страниц и других целей, включая worker. browser.pages() охватывает страницы во всех контекстах, тогда как context.pages() ограничивает список одним контекстом. Поэтому поиск на уровне всего браузера может захватить страницу, созданную другим тестом. Вспомогательные функции должны явно принимать Page или BrowserContext, а не искать во всём браузере страницу, URL которой случайно подходит в данный момент.

У изоляции также есть чёткий предел. Отдельные контексты предотвращают случайное совместное использование cookie и кеша под управлением браузера, но они не являются отдельными песочницами операционной системы и не создают отдельные сервисные аккаунты. Приложение по-прежнему может записать данные во внешнюю базу, отправить письмо, создать запись у платёжного провайдера или сохранить серверный сеанс после исчезновения страницы. Контекст браузера владеет клиентской средой просмотра. Приложение и сервис по-прежнему отвечают за поддерживаемые ими выход из аккаунта, откат и хранение данных.

Чтобы объяснить этот жизненный цикл, не нужно придумывать универсальный объект «состояния хранилища». Puppeteer предоставляет конкретные операции для cookie, страниц, разрешений и целей контекста. У других хранилищ свои правила origin и API. Экспорт cookie не является полным снимком локального хранилища, IndexedDB, хранилища кеша, сервис-воркеров, JavaScript в памяти или состояния удалённого сеанса. Эти границы описаны в модели хранилища браузера; владелец каждого хранилища должен оставаться явным.

Создавайте контекст и очищайте его в одной области видимости

Наиболее надёжная форма фикстуры: получение ресурса, сразу после которого следует блок try и гарантированно выполняемый путь очистки. Не создавайте контекст в одном модуле, не передавайте только его страницу через несколько уровней и не надейтесь, что глобальный обработчик завершения позже обнаружит потерянного владельца. Код, создающий контекст, должен сохранять его дескриптор, даже если в обычном сценарии используется только страница.

В этом примере один разрешённый сценарий получает один контекст и одну страницу. Он также сохраняет и ошибку рабочего процесса, и ошибку очистки. Первая остаётся причиной сбоя теста, а вторая остаётся видимой, а не молча заменяет или скрывает первую.

import type { Browser } from 'puppeteer';

async function runCheckoutCheck(browser: Browser) {
  const context = await browser.createBrowserContext();
  let workflowFailed = false;
  let workflowError: unknown;

  try {
    const page = await context.newPage();
    page.setDefaultTimeout(10_000);
    page.setDefaultNavigationTimeout(20_000);

    await page.goto('https://example.test/checkout', {
      waitUntil: 'domcontentloaded',
    });
    await page.locator('[data-test="cart-total"]').wait();
  } catch (error) {
    workflowFailed = true;
    workflowError = error;
  }

  let cleanupFailed = false;
  let cleanupError: unknown;
  try {
    await context.close();
  } catch (error) {
    cleanupFailed = true;
    cleanupError = error;
  }

  if (workflowFailed && cleanupFailed) {
    throw new AggregateError([workflowError, cleanupError], 'Сбой рабочего процесса и очистки BrowserContext');
  }
  if (workflowFailed) throw workflowError;
  if (cleanupFailed) throw cleanupError;
}

Создание контекста до входа в основной рабочий процесс здесь допустимо: отклонённый промис createBrowserContext() означает, что возвращённого дескриптора контекста для закрытия нет. Если настройка включает несколько шагов получения ресурсов, регистрируйте каждый ресурс сразу после успешного получения. Страница, поток скачивания, временный каталог или запись могут дать сбой после создания контекста, но до начала сценария. Очистка должна работать при частичной настройке, а не предполагать, что каждая переменная была инициализирована.

Обычно не требуется закрывать каждую страницу перед закрытием её контекста. BrowserContext.close() закрывает контекст и все связанные страницы. Явные вызовы page.close() полезны, когда страница живёт меньше контекста, когда тест должен проверить закрытие всплывающего окна или когда в длительном сценарии страницу нужно освободить раньше. Они не должны подменять закрытие контекста, который владеет всей группой.

Если контракт приложения требует завершения работы на его стороне, это следует делать до завершения работы браузера. Например, синтетическая проверка оформления заказа может требовать поддерживаемого вызова отмены, а тестовому аккаунту может понадобиться обычное действие выхода из приложения. Выполните эту операцию, пока страница и контекст ещё работают, а затем закройте ресурсы браузера. Если очистка приложения завершилась с ошибкой, всё равно попытайтесь очистить контекст и сообщите оба результата. Никогда не удаляйте общие файлы или несвязанные записи сервиса только потому, что закрыть браузер полностью не удалось.

Вывод фикстуры должен быть меньше управляемых ею ресурсов. Полезный результат содержит имя сценария, версию браузера, результат создания контекста, результат страницы и результат очистки. Значения cookie, заголовки авторизации, полный HTML или персональные данные в нём не нужны. Скриншоты и трассировки также могут содержать учётные данные или пользовательский контент, поэтому собирайте их только для разрешённого теста и храните согласно обычной политике команды в отношении артефактов.

Считайте страницы и всплывающие окна ресурсами контекста

Объект Page всегда принадлежит контексту браузера. page.browserContext() позволяет коду проверить эту связь, однако лучше передать правильного владельца, чем выяснять его после сбоя. Вспомогательная функция, открывающая отчёт, должна получить страницу или контекст сценария. Она не должна вызывать browser.newPage(), если только помещение новой страницы в контекст по умолчанию не является намеренным. Иначе вспомогательная функция может пересечь границу изоляции без очевидной ошибки.

Всплывающие окна создают вторую проблему владения: новую страницу создаёт поведение браузера, а не прямой вызов context.newPage(). Всплывающее окно всё равно принадлежит контексту, но тест должен начать наблюдать за ним до пользовательского действия, которое может его создать. Ожидание после нажатия приводит к гонке. Быстрое всплывающее окно может появиться до регистрации слушателя или предиката цели, и тест останется ждать уже произошедшего события.

Puppeteer предоставляет событие страницы popup и поиск целей на уровне контекста. Ожидание цели в пределах контекста полезно при совместном использовании браузера, поскольку оно не может случайно выбрать цель из другого контекста. Сначала зарегистрируйте промис, затем инициируйте действие и после этого ожидайте уже зарегистрированный промис.

const popupTargetPromise = context.waitForTarget(target => target.opener() === page.target(), { timeout: 10_000 });

const [popupTarget] = await Promise.all([popupTargetPromise, page.locator('[data-test="open-receipt"]').click()]);
const popup = await popupTarget.page();
if (!popup) {
  throw new Error('Открытая цель не является страницей');
}

await popup.locator('[data-test="receipt-number"]').wait();
await popup.close();

Предикат исходной страницы важен. В занятом контексте недостаточно ждать следующую цель типа page: несвязанное фоновое действие может раньше создать другую страницу. Условие target.opener() === page.target() связывает результат со страницей, выполнившей действие. Если приложение намеренно повторно использует существующую вкладку вместо открытия всплывающего окна, применяйте соответствующее условие навигации или содержимого, а не навязывайте рабочему процессу предположение о всплывающем окне.

Для навигации действует то же правило порядка. Когда нажатие должно привести к навигации, создайте промис page.waitForNavigation() до нажатия и ожидайте обе операции вместе. Когда нажатие обновляет страницу без навигации, ждите конкретного видимого условия или ответа. Универсальная задержка не доказывает, что ожидаемый переход состоялся, а networkidle не является универсальным сигналом готовности приложения для страниц, которые постоянно держат открытыми запросы аналитики, потоковой передачи или фоновые запросы.

Очистка всплывающего окна следует дереву контекста. Закрытие всплывающего окна освобождает эту страницу, сохраняя родительскую страницу и контекст. Закрытие контекста освобождает обе страницы. Закрытие только исходной страницы не гарантирует завершение всех открытых ею страниц, поэтому по окончании всего сценария очистка должна опираться на границу контекста. Перед проверкой в середине сценария context.pages() может предоставить ограниченный список для подсчёта или проверки владения без поиска в других контекстах.

Worker и скачивания требуют такой же дисциплины, хотя не все они представлены объектами Page. Worker может продолжать активность приложения после перехода страницы, а скачивание может жить дольше инициировавшего его нажатия. Дождитесь завершения принадлежащей тесту работы, которая обязана завершиться, отмените её через поддерживаемый API, если отмена является частью сценария, а затем закройте владеющий контекст. Закрытие контекста освобождает ресурсы браузера, но не может отменить удалённое изменение, уже отправленное worker, или файл, который тестовый runner уже переместил в другое место.

Пусть тайм-аут описывает несбывшееся ожидание, а не очистку

Тайм-аут представляет собой границу наблюдения. Он задаёт, сколько тест ждёт определённого условия; он не доказывает, что браузер остановил основную работу приложения. Если waitForSelector, ожидание локатора, навигация или waitForTarget завершаются по тайм-ауту, контекст всё ещё может быть открыт, а страница может продолжать выполнение. Поэтому очистка необходима после тайм-аута так же, как и после сбоя проверки.

Puppeteer разделяет общие значения по умолчанию и значения для навигации. page.setDefaultTimeout(ms) задаёт стандартный максимум для методов, использующих настройку тайм-аута страницы. page.setDefaultNavigationTimeout(ms) управляет методами навигации и имеет для них приоритет. Явная опция метода понятнее всего, когда одному действию обоснованно нужен другой срок. Выбирайте значения на основе тестовой среды и ожидаемого видимого пользователю перехода, а не исходя из обещания, что любой сайт завершит работу за одно универсальное время.

Тайм-ауты должны называть защищаемое ими условие. Ожидание цели всплывающего окна должно сообщать, что всплывающее окно от ожидаемой исходной страницы не появилось. Ожидание селектора должно называть состояние приложения, которое не стало видимым. Тайм-аут навигации должен отличать отсутствующую навигацию от полученного HTTP-ответа с неожиданным статусом. Замена всего этого одним внешним тайм-аутом теста даёт один расплывчатый сбой и заставляет очистку конкурировать с уже исчерпанным сроком.

На уровне тестового runner выделите отдельный бюджет для завершения работы. Методы Puppeteer context.close() и browser.close() не принимают те же опции тайм-аута отдельных действий, что и ожидания страницы. Runner может установить внешний срок, но Promise.race() лишь прекращает ожидание: он не отменяет проигравший промис закрытия и не доказывает исчезновение ресурсов браузера. Если тестовая обвязка сообщает о превышении срока закрытия, она должна пометить очистку как незавершённую и передать завершение процесса компоненту, который действительно владеет процессом браузера.

Не проглатывайте TimeoutError Puppeteer и не продолжайте работу на странице в неизвестном состоянии. Действие, прерванное по тайм-ауту, могло частично выполниться. Повтор покупки, отправки формы или разрушающей операции сервиса на той же странице может продублировать эффект. Сначала определите, было ли неудачное условие доступным только для чтения и допускающим повтор. При повторяемом инфраструктурном сбое закройте старый контекст, создайте новый с теми же разрешёнными синтетическими входными данными и выполните новую попытку. При неопределённости на стороне приложения перед повтором используйте поддерживаемый приложением контракт идемпотентности или проверки статуса.

Ошибкам очистки тоже нужна отдельная категория. Ошибка закрытия страницы, ошибка закрытия контекста и разрыв соединения с браузером не равнозначны проверке приложения. Сначала сообщайте исходную ошибку рабочего процесса и прикрепляйте ошибки очистки, как в примере фикстуры с AggregateError. Так сохраняется свидетельство тайм-аута селектора и одновременно видно, что завершение работы было неполным. Блок finally, который выбрасывает новую ошибку закрытия, не сохранив исходное исключение, затрудняет диагностику теста.

Не используйте неограниченный обработчик завершения процесса как основной механизм очистки. Обработчики завершения полезны как последняя диагностическая граница, но асинхронная работа может завершаться не во всех режимах остановки. Очистка отдельных тестов и фикстур обеспечивает детерминированное владение, пока цикл событий и соединение исправны. Затем владелец уровня набора тестов должен закрыть общий браузер после завершения всех владельцев контекстов, применяя собственную ограниченную политику завершения.

Выбирайте close или disconnect согласно владению процессом

page.close(), context.close(), browser.close() и browser.disconnect() намеренно имеют разные последствия. Выбор между ними является не вопросом стиля, а решением о владении процессом.

ОперацияЧто она завершаетЧто остаётся
page.close()Одну страницуЕё контекст, соседние страницы и браузер
context.close()Контекст не по умолчанию и все связанные страницыДругие контексты и браузер
browser.close()Браузер и все связанные страницыТестовый процесс Node.js и его ресурсы вне браузера
browser.disconnect()Соединение Puppeteer с браузеромПроцесс браузера и его страницы продолжают работать

По умолчанию Page.close() не запускает обработчики beforeunload. С runBeforeUnload: true метод запускает эти обработчики, но Puppeteer не ждёт фактического закрытия страницы. Используйте такое поведение только тогда, когда обработчик входит в проверяемый сценарий. Очистка не должна полагаться на обработчик выгрузки приложения для удалённого отката, а успешное закрытие в этом режиме не доказывает, что завершилась локальная или удалённая очистка.

Метод Browser.close() закрывает браузер и все связанные страницы. Обычно это правильное финальное действие, когда фикстура владеет запущенным ею браузером. Владельцы контекстов должны сначала закрыть свои контексты, чтобы сбой можно было связать с правильным сценарием, а затем владелец браузера на уровне набора тестов закрывает ресурс всего процесса. Единственный вызов browser.close() в конце может освободить ресурсы, но скроет, какой тест допустил утечку контекста или страницы во время выполнения.

Метод Browser.disconnect() отключает Puppeteer, оставляя процесс браузера запущенным. Это уместно, когда другой компонент владеет долгоживущим браузером, а текущий клиент владеет только своим соединением. Это не является очисткой страниц, контекстов, cookie, скачиваний или серверных сеансов. После отключения этот клиент не может продолжать управлять такими объектами через отключённый дескриптор Browser. Владелец процесса должен сохранить отдельный канал управления и явную политику завершения.

Использование puppeteer.launch() или puppeteer.connect() служит полезной подсказкой о владении, но решающим является контракт развёртывания. Процесс, запущенный worker, обычно принадлежит этому worker. Процесс, доступный через WebSocket endpoint браузера, часто принадлежит сервису. Код не должен закрывать общий сервис только потому, что API это позволяет, и не должен отключаться от собственного браузера, а затем заявлять, что процесс освобождён.

Закрытие не стирает внешние эффекты. context.close() удаляет живой контекст и его страницы; оно не отзывает токен, уже скопированный в другое место, не отменяет заказ, не удаляет тестовый аккаунт и не очищает скачанный тестовым runner файл. Если рабочий процесс должен очистить данные сайта, сохраняя контекст живым, используйте точный механизм браузера или приложения и проверьте его документированную область действия. Руководство по очистке данных сайта объясняет, почему удаление клиентских данных и очистка аккаунта являются разными утверждениями.

Проверяйте очистку наблюдаемыми условиями

Выполняйте небольшие проверки с той версией браузера и формой фикстуры, которые действительно используете. Следующие наблюдения получены на активированной локальной странице в стандартном Chrome 154 с Puppeteer 24.40.0; они дают условия приёмки для теста, а не универсальные гарантии времени или проверку BotBrowser.

ПроверкаМинимальное действиеНаблюдайте перед продолжениемНе делайте вывод
Закрытие страницы по умолчаниюДобавьте обработчик beforeunload, затем вызовите page.close() без runBeforeUnloadДиалог не наблюдается, а page.isClosed() возвращает trueЧто каждый путь закрытия покажет диалог выгрузки
Закрытие с включённой выгрузкойАктивируйте локальную страницу, вызовите page.close({ runBeforeUnload: true }) и примите диалогПромис закрытия может завершиться при page.isClosed() равном false; дождитесь закрытого состояния после принятия диалогаЧто фиксированная задержка гарантирует закрытие или удалённую очистку
Изоляция принадлежащих контекстовУстановите разные синтетические cookie и значения локального хранилища в двух контекстах одного originКаждый контекст читает только собственное значениеЧто отдельные контексты изолируют серверные аккаунты или ресурсы операционной системы
Закрытие контекста и отключениеЗакройте принадлежащий контекст, затем отключите клиента, использующего endpoint браузераЕго страница закрыта, а браузер остаётся подключённым после закрытия контекста; после отключения снова подключитесь через endpoint, прежде чем использовать дескрипторы браузераЧто disconnect() завершает процесс браузера или очищает удалённое состояние

Повторяйте эти проверки после обновления браузера или Puppeteer и ждите наблюдаемого закрытого состояния, а не таймера. Они описывают один ограниченный локальный случай, а не результат для внешнего сайта, проверку профиля или гарантию очистки приложения.

Применяйте модель к BotBrowser, не расширяя утверждения

Когда Puppeteer управляет процессом Chromium в BotBrowser, стандартные API Puppeteer Browser, BrowserContext, Target и Page по-прежнему определяют дерево ресурсов автоматизации. Проверенная специфика BotBrowser уже: он документирует отдельные хранилища и сеансы контекста, а также элементы управления профилем конкретного контекста для документированной лицензии. Эти настройки назначаются до создания страниц, поскольку renderer считывает конфигурацию контекста при запуске. BotBrowser не заменяет context.close(), очистку страниц, завершение работы приложения, инвалидирование серверных сеансов или обращение с секретами. Он не может заставить browser.disconnect() завершить процесс, превратить тайм-аут страницы в отмену, отменить удалённый запрос или гарантировать завершение обработчика выгрузки. Puppeteer и тестовая фикстура по-прежнему владеют очисткой автоматизации; приложение и сервис по-прежнему владеют поддерживаемой удалённой очисткой.

Этот порядок подкрепляет правило владения. Создайте контекст, примените к нему документированную и доступную по лицензии конфигурацию через поддерживаемую интеграцию и только затем создавайте страницы в этом контексте. Используйте одну разрешённую синтетическую идентичность на контекст. Базовый профиль браузера не даёт разрешения незаметно использовать аккаунт другого контекста, а профиль конкретного контекста не является сериализованным снимком Puppeteer для всех механизмов веб-хранилища.

Доступность возможностей также должна быть частью контракта. В документации BotBrowser перечислены предварительные условия для полной поддержки отпечатков отдельных контекстов, включая соответствующую корпоративную лицензию. Команда должна проверить установленную сборку, совместимость профиля и лицензию, прежде чем полагаться на настройки конкретного контекста. Безопасный резервный вариант состоит в том, чтобы не заявлять о работе неподдерживаемых флагов. Завершите настройку с ошибкой до создания страницы либо запускайте сценарий, использующий только действительно доступные в этой среде возможности.

Для ограниченной проверки записывайте только версию браузера, название синтетического сценария, результат создания контекста, число ожидаемых страниц и результат очистки. Подтвердите, что два тестовых контекста не разделяют известные входные cookie или кеш, используемые тестом. Затем закройте каждый контекст через владеющую им фикстуру и закройте браузер или отключитесь от него согласно контракту процесса. Это проверяет выбранную конфигурацию, но не доказывает, что удалённые аккаунты нельзя связать или что сервис удалил собственные данные.

Перед тем как считать тест жизненного цикла завершённым, выполните следующую проверку:

  1. Назовите владельца процесса браузера и решите, будет ли финальным действием close() или disconnect().
  2. Создайте контекст не по умолчанию для каждого изолированного сценария и создавайте страницы из этого контекста.
  3. Регистрируйте ожидания всплывающего окна, цели или навигации до действия, которое может их выполнить.
  4. Придавайте ожиданиям действий явный смысл и сохраняйте первый сбой во время очистки.
  5. Проверяйте очистку приложения отдельно, затем закрывайте контекст и сообщайте о любой незавершённой очистке.

Такая последовательность также упрощает проверку обновлений браузера. Вызовы API видимы, граница контекста явна, а утёкшее всплывающее окно не может скрыться за завершением всего процесса. Для состояния, связанного с сервис-воркером, используйте руководство по жизненному циклу его кеша, а не считайте закрытие страницы гарантией очистки кеша или удалённых данных.

Источники

#Puppeteer#BrowserContext#Page#Изоляция Тестов#Очистка

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.