Загрузка расширений в Puppeteer и Playwright
Распакованному расширению нужен постоянный контекст и пара флагов, и оно всё равно может не загрузиться. Измеренный запуск показывает, почему загрузку нужно проверять.
BotBrowser Team
Нужна структурированная документация по теме Платформа?
Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.
Загрузка распакованного расширения из автоматизированного запуска выглядит как задача о двух флагах, а ведёт себя как задача об условиях запуска. BotBrowser предоставляет объявленный профиль, который сохраняет согласованность поверхностей, доступных странице, между запусками, включая настройки уровня запуска, и он не заставляет браузер принять расширение, которое сборка отказывается загружать, и не гарантирует, что сервис-воркер останется активным. Условия, необходимые загруженному расширению, можно перечислить, и один измеренный запуск показал все их выполненными, хотя расширения в итоге не оказалось.
Кратко
- Распакованному расширению нужен постоянный контекст, потому что непостоянный контекст не может сохранить состояние расширения между страницами.
- Два флага предназначены для совместной передачи: один называет то, что разрешено, другой исключает всё остальное.
- Передача только флага загрузки оставляет набор расширений по умолчанию, а это меняет то, что видит страница.
- Сервис-воркер MV3 не является постоянным объектом, поэтому отсутствующий воркер не доказывает, что расширение не загрузилось.
- В одном измеренном запуске оба флага были переданы при
headless=new, браузер завершился без ошибок, а профиль показал ноль расширений. - То же произошло в старом headless-режиме и в режиме с интерфейсом на том же хосте, поэтому сбой был средовым, а не связанным с режимом.
- Полезная реакция состоит в том, чтобы утверждать наличие расширения при старте и останавливать запуск, когда его нет.
- Тест, который молча работает без своего расширения, проходит по неверной причине, и это хуже, чем тест, который падает.
Содержание
- Условия запуска и как выглядит измеренный сбой
- Сервис-воркер не постоянен, и контрольный список
- Контрольный список в виде фикстуры
- Когда headless не является переменной
- Практический итог
Условия запуска и как выглядит измеренный сбой
Три условия решают, есть ли у распакованного расширения шанс, и они независимы. Первое это тип контекста. Расширение живёт в профиле браузера, поэтому контексту, который выбрасывает профиль в конце, негде его хранить. Постоянный контекст с явным --user-data-dir это единственная форма, которая держится, и каждый инструмент выражает это по-своему: Playwright принимает каталог первым аргументом chromium.launchPersistentContext, а Puppeteer достигает того же профиля через опции запуска и аргументы командной строки. Аргументы запуска разбираются в подводных камнях запуска и профиля Puppeteer, включая переопределение viewport, которое перевешивает дисплей из профиля.
persistent context -> profile on disk; extension state can persist
non-persistent -> fresh profile per launch; nothing to hold the extension
Второе это пара флагов. Один флаг называет каталог расширения для загрузки, другой называет единственный набор расширений, которым разрешено работать. Передача только флага загрузки добавляет расширение ко всему, что уже есть в профиле, а тесту это редко нужно; передача обоих делает набор расширений запущенного браузера равным задуманному.
--disable-extensions-except=/path/to/ext
--load-extension=/path/to/ext
Третье условие в том, что сборка браузера вообще принимает каталог. Это не формальность: сборка с ограниченной загрузкой расширений, манифест, который сборка считает недопустимым, и путь, который браузер не может прочитать, дают один и тот же наблюдаемый итог, а именно отсутствие расширения.
Три условия выше легко сформулировать и легко счесть выполненными. Один запуск был записан, чтобы проверить это убеждение, с минимальным расширением MV3 с фоновым сервис-воркером и двумя флагами, переданными вместе.
persistent_headless_new: headless=true persistent=true extLoaded=false sw_count=0 id=(none)
persistent_headed: headless=false persistent=true extLoaded=false sw_count=0 id=(none)
Затем профиль был прочитан после выхода браузера, а это проверка, не зависящая от момента времени внутри работающего браузера:
--headless=new: exit=0 prefs_extension_ids=0
--headless: exit=0 prefs_extension_ids=0
(headed): exit=0 prefs_extension_ids=0
Три режима, три чистых выхода, ноль расширений в профиле. Этот результат стоит прочитать внимательно, потому что он говорит более узкую вещь, чем «расширения не работают». Он говорит, что на этом хосте, с этой сборкой и этим каталогом передача обоих документированных флагов не установила расширение, и браузер не сообщил об этом никакой ошибки.
Практическое следствие в том, что тест, который предполагает успех флагов, будет работать без своего расширения и всё равно пройдёт. extLoaded=false это поле, которое фиксирует загрузку, а чтение профиля после выхода сообщает prefs_extension_ids=0; счётчик воркеров равен sw_count=0, но одно это значение не отличает неудачную загрузку от воркера, который так и не запустился. Каждая проверка внутри расширения пропускается, тестируемая страница видит окружение по умолчанию, а запуск сообщает об успехе. Это хуже видимого сбоя, потому что ничто в выводе не приглашает посмотреть второй раз.
Исправление небольшое и механическое, но два инструмента показывают разные доказательства. Puppeteer перечисляет установленное, поэтому проверка идёт по этому списку. У Playwright нет метода для установленного набора, поэтому тот же факт читается из профиля после выхода браузера, где extensions.settings индексируется идентификатором расширения.
puppeteer: installed = await browser.extensions() // Map: id -> {name, version}
playwright: JSON.parse(readFileSync(join(userDataDir, 'Default', 'Secure Preferences')))
.extensions.settings // keys are extension ids
assert installed.size > 0, "no extension installed; check flags, manifest and build"
Проверка воркера отвечает на другой вопрос и не может заменить эту проверку. Ожидание context.serviceWorkers() в Playwright или browser.waitForTarget с предикатом service_worker в Puppeteer подтверждает, что фоновый контекст запустился; ограниченное ожидание с бюджетом вроде 30_000 ms годится, если сообщение об ошибке говорит, что воркер не запустился, а не что расширение не загрузилось.
Сервис-воркер не постоянен, и контрольный список
Есть вторая причина, по которой проверка по списку воркеров может вводить в заблуждение, и это свойство платформы расширений, а не инструмента автоматизации. Фоновый сервис-воркер MV3 запускается, когда он кому-то нужен, и останавливается, когда простаивает. Отсутствующий воркер может быть тем, что ещё не запускался, или тем, что уже был приостановлен.
absent worker + extension installed -> not started, or suspended
absent worker + extension not present -> load failed
Оба выглядят одинаково при одном опросе и означают разное, поэтому проверка загрузки относится к установленному набору, а проверка воркера остаётся отдельной проверкой живучести со своим сообщением. Чтение профиля после выхода или запрос к браузеру об установленных расширениях их различает; опрос списка воркеров в течение фиксированного числа секунд этого не делает.
Жизненный цикл также меняет то, как тест должен ждать воркер. Фиксированная пауза, достаточная для быстрой машины, окажется слишком короткой на медленной, а тест, который ждёт появления воркера и сдаётся через фиксированный интервал, измеряет машину, а не расширение. Ожидание условия с ограниченным бюджетом вроде 30_000 ms и сообщение об этом бюджете при его истечении дают сбой, который называет свою причину.
Четвёртое условие заслуживает отдельного абзаца, потому что оно меняется вместе с инструментом, а не с браузером. Puppeteer и Playwright оба предоставляют список аргументов на момент запуска и оба принимают те же два флага, но контекст, который они строят по умолчанию, различается. Puppeteer запускает браузер и создаёт страницу в его контексте по умолчанию; Playwright запускает браузер и позволяет вызывающему коду создавать контексты. Расширение живёт на уровне браузера в обоих случаях, а значит, оно видно из постоянного контекста и невидимо из контекста, созданного непостоянным путём.
puppeteer.launch({ args }) -> browser-level flags; page may still be transient
chromium.launchPersistentContext(dir) -> profile on disk; extension state survives
browser.newContext() -> fresh profile; extension state absent
Именно поэтому две команды могут сообщать противоположные результаты при одних и тех же документированных флагах. Одна передаёт их запуску, чей профиль сохраняется, другая передаёт их запуску, а затем создаёт контекст, который профиль выбрасывает. Ни одна не ошибается в том, что наблюдала, и один флаг не объясняет разницу.
Второй источник путаницы это версия манифеста. Расширение MV2 и расширение MV3 падают по-разному: у MV2 есть постоянная фоновая страница, которая делает расширение очевидным сразу после загрузки, а MV3 запускает сервис-воркер только когда что-то его попросит. Читатель, перешедший со старого расширения на новое, может поэтому увидеть изменение поведения, которое является разницей манифеста, а не сбоем загрузки, и проверка, которая их различает, должна смотреть на установленный набор, а не на работающий воркер.
MV2 -> persistent background page; visible immediately
MV3 -> service worker; starts on demand, suspends when idle
Обе эти разницы указывают на одну дисциплину: проверять то, что не может быть неоднозначным. Установленный набор расширений именно таков, потому что он либо содержит расширение, либо нет, и он не зависит от того, работает ли воркер в момент опроса.
Контрольный список в виде фикстуры
Проработанный контрольный список закрывает тему, потому что условия проще применять как упорядоченный список, чем как прозу. У каждого шага есть сбой, который он предотвращает, и порядок важен: тип контекста решает, может ли работать остальное, поэтому он первый.
1. launch a persistent context with an explicit user-data directory
2. pass --disable-extensions-except and --load-extension with the same path
3. validate the manifest before launch
4. read the installed extension set after launch
5. assert the set contains the extension, and fail with the four recorded fields
Первый шаг пропускают чаще всего, потому что вызов chromium.launch() выглядит так, будто должен работать, а сбой происходит молча. Читатель, который добавляет флаги к запуску, создающему новый контекст, делает правильную вещь на неправильном уровне, и наблюдаемый результат тот же, что при передаче никаких флагов.
Третий шаг пропускают потому, что некорректный manifest.json обычно даёт сообщение, которое называет браузер, а не файл. Проверка JSON до запуска превращает это в ошибку разбора с номером строки, то есть в пятисекундное исправление вместо пятнадцатиминутного поиска по параметрам запуска.
Шаги четыре и пять превращают молчаливый проход в громкий сбой. Без них тест, зависящий от расширения, выполнит свои проверки на странице, которой расширение никогда не касалось, и отчёт скажет, что проверки прошли. Это самый дорогой исход в этой теме, потому что работа по написанию теста уже сделана, а результат ничего не значит.
Список достаточно короток, чтобы жить в фикстуре, а не в документе, и это его правильное место. Фикстура, которая выполняет пять шагов один раз и отдаёт получившийся контекст, означает, что каждый тест набора наследует проверку вместо её повторения, а сломанное окружение падает в фикстуре, а не внутри постороннего теста.
Когда headless не является переменной
Ещё одно измерение стоит записать, потому что оно убирает гипотезу, которую читатель иначе сформирует. Предыдущий сбой можно было бы разумно списать на headless-режим, поскольку именно headless-браузеры чаще всего описывают как не поддерживающие расширения. Поэтому тот же каталог и те же флаги были испробованы в режиме с интерфейсом на том же хосте.
persistent_headless_new: extLoaded=false
persistent_headed: extLoaded=false
Режим с интерфейсом упал так же, что исключает --headless как причину на этом хосте и указывает на сборку или каталог. Это полезный отрицательный результат: он избавляет читателя от вечера переключений между headless-режимами, когда ни один из них не является переменной. Отрицательный результат, устраняющий одно из трёх условий, стоит столько же, сколько положительный, подтверждающий другое.
Это чтение также напоминает, как такие результаты следует записывать. «Расширения не работают в headless» это утверждение, которое измерение не поддерживает, и это утверждение, на которое читатель стал бы опираться. «На этом хосте, с этой сборкой и этим каталогом оба headless-режима и режим с интерфейсом сообщили об отсутствии расширения» это утверждение, которое измерение поддерживает, и оно указывает на другой следующий шаг.
Та же дисциплина применима к положительному случаю. Запуск, в котором расширение загружается, доказывает, что три условия были выполнены для этой сборки на этом хосте, и не доказывает, что флаги верны вообще. Запись четырёх полей позволяет более позднему читателю понять, какое из этих двух утверждений запуск действительно установил.
loaded + recorded build -> this build accepted this directory
loaded + no build -> something worked, cause unknown
Последнее замечание об отношении между расширением и значениями, которые сообщает страница, потому что именно поэтому тема стоит рядом с согласованностью профиля, а не в отдельном руководстве по инструменту. Расширение может читать и изменять документ, в котором работает, и делает это внутри того же браузера, который описывает профиль. Значит, расширение, которое меняет поверхность страницы, становится частью того, что страница сообщает, а профиль, который этого не учитывает, описывает браузер, не являющийся тестируемым. То же рассуждение применимо к любому переопределению, которое страница может наблюдать, и сигналы автоматизации и их согласованность рассматривают его как вопрос совпадения поверхностей, а не как список добавлений.
profile without extension -> describes the browser alone
profile with extension -> describes the browser plus what the extension changed
Практическое следствие в том, что расширение принадлежит той же записи, что сборка и профиль, по той же причине, что и оба они. Значение, которое меняется при добавлении расширения, не является дефектом ни одного из них; это ожидаемый результат появления ещё одного участника внутри документа, и запись должна это сказать, иначе более поздний читатель припишет изменение неверной причине.
Именно поэтому тестовое окружение, которое молча теряет расширение, даёт результат, который выглядит чистым и таковым не является. Страница сообщает значения браузера без расширения, проверки, зависящие от расширения, никогда не выполняются, а отчёт внутренне согласован насчёт конфигурации, которая не использовалась. Проверка из списка выше существует ровно для того, чтобы предотвратить такой исход, и это та строка кода, которая делает остальную часть темы применимой.
Практический итог
Строй запуск в том порядке, который ожидает платформа: постоянный контекст с явным каталогом профиля, оба флага вместе и проверка сразу после запуска, что расширение действительно присутствует. Считай список воркеров доказательством только когда он не пуст, потому что отсутствующий воркер неоднозначен, а отсутствующая установка нет. Проверяй манифест до запуска, а не после, чтобы недопустимый файл падал с сообщением о файле, а не с таймаутом на воркере.
Когда проверка падает, запиши, что было передано и что сообщил браузер, потому что три условия выше это всё пространство поиска. Запуск, который записывает только «расширение не загрузилось», оставляет следующего читателя гадать, какое из трёх условий нарушено, и догадка обычно падает на флаги, когда причина в сборке.
О том, что расширение может видеть после запуска, читай изоляцию контентных скриптов расширения браузера; о том, какая сборка установлена и как её зафиксировать, читай фиксацию версии Chrome for Testing.
Замечание о том, где живёт расширение, относится к условиям, потому что читатель, переносящий тест между машинами, часто переносит и расширение, не замечая этого. Распакованное расширение загружается по пути файловой системы, и этот путь является частью запуска, а не теста. Контейнер, который монтирует расширение в другом месте, поэтому требует другого значения флага, а кэшированная конфигурация запуска с жёстко заданным путём молча падает на другой машине.
host A: --load-extension=/work/ext
host B: --load-extension=/opt/ci/ext (same extension, different path)
Идентификатор расширения выводится из пути для распакованного расширения, поэтому перемещённый каталог даёт другой идентификатор вместе с другим значением флага. Всё, что опирается на идентификатор, например ключ хранилища или запись разрешений, перестаёт совпадать в тот же момент, и сбой выглядит как проблема разрешений, а не проблема пути.
Именно поэтому четыре записываемых поля включают строку флагов, а не булево значение вроде loadExtension=true. Булево значение переживает перенос машины неизменным и скрывает разницу, тогда как строка показывает, что путь изменился, и объясняет одним взглядом и отсутствующее расширение, и изменённый идентификатор.
То же рассуждение применимо к упакованному расширению, установленному из магазина. Идентификатор стабилен, а путь не виден, поэтому упакованное расширение может переезжать между машинами, ничего не меняя в том, что записывает читатель; тогда режимом сбоя становится несовпадение версий, а не несовпадение путей, и записывать нужно версию расширения вместо каталога.
Источники
Эта статья входит в руководство: Поверхности веб-платформы: карта проверки
Похожие статьи
Chrome headless новый и старый: shell, режим и различия
ПлатформаИзоляция content script в расширениях браузера
ПлатформаWidevine в автоматизированных браузерах: почему EME не работает
ПлатформаЗапуск профилей Windows-браузера на macOS и Linux
СравнениеЯдро браузера и скрипты JavaScript: согласованность отпечатка
Начало работыСбор данных с Puppeteer: настройки и наблюдения
Выполните ту же проверку в BotBrowser
Загрузите профиль, сохраните внутри браузера те сигналы, о которых только что прочитали, и подтвердите результат на своей машине до раскатки.