Skip to content

[py][bidi]: add enable_webextensions option for chromium-based browsers #15794

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions py/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,29 @@ def firefox_options(request):
if request.config.option.headless:
options.add_argument("-headless")
return options


@pytest.fixture
def chromium_options(request):
try:
driver_option = request.config.option.drivers[0].lower()
except (AttributeError, TypeError):
raise Exception("This test requires a --driver to be specified")

# Skip if not Chrome or Edge
if driver_option not in ("chrome", "edge"):
pytest.skip(f"This test requires Chrome or Edge, got {driver_option}")

# skip tests in the 'remote' directory if run with a local driver
if request.node.path.parts[-2] == "remote" and get_driver_class(driver_option) != "Remote":
pytest.skip(f"Remote tests can't be run with driver '{driver_option}'")

if driver_option == "chrome":
options = webdriver.ChromeOptions()
elif driver_option == "edge":
options = webdriver.EdgeOptions()

if request.config.option.headless:
options.add_argument("--headless=new")

return options
34 changes: 34 additions & 0 deletions py/selenium/webdriver/chromium/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def __init__(self) -> None:
self._extensions: list[str] = []
self._experimental_options: dict[str, Union[str, int, dict, list[str]]] = {}
self._debugger_address: Optional[str] = None
self._enable_webextensions: bool = False

@property
def binary_location(self) -> str:
Expand Down Expand Up @@ -126,6 +127,39 @@ def add_experimental_option(self, name: str, value: Union[str, int, dict, list[s
"""
self._experimental_options[name] = value

@property
def enable_webextensions(self) -> bool:
"""Returns whether webextension support is enabled for Chromium-based browsers.

:Returns: True if webextension support is enabled, False otherwise.
"""
return self._enable_webextensions

@enable_webextensions.setter
def enable_webextensions(self, value: bool) -> None:
"""Enables or disables webextension support for Chromium-based browsers.

When enabled, this automatically adds the required Chromium flags:
- --enable-unsafe-extension-debugging
- --remote-debugging-pipe

:Args:
- value: True to enable webextension support, False to disable.
"""
self._enable_webextensions = value
if value:
# Add required flags for Chromium webextension support
required_flags = ["--enable-unsafe-extension-debugging", "--remote-debugging-pipe"]
for flag in required_flags:
if flag not in self._arguments:
self.add_argument(flag)
else:
# Remove webextension flags if disabling
flags_to_remove = ["--enable-unsafe-extension-debugging", "--remote-debugging-pipe"]
for flag in flags_to_remove:
if flag in self._arguments:
self._arguments.remove(flag)

def to_capabilities(self) -> dict:
"""Creates a capabilities with all the options that have been set
:Returns: A dictionary with everything."""
Expand Down
14 changes: 12 additions & 2 deletions py/selenium/webdriver/common/bidi/webextension.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from typing import Union

from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.bidi.common import command_builder


Expand Down Expand Up @@ -54,8 +55,17 @@ def install(self, path=None, archive_path=None, base64_value=None) -> dict:
extension_data = {"type": "base64", "value": base64_value}

params = {"extensionData": extension_data}
result = self.conn.execute(command_builder("webExtension.install", params))
return result

try:
result = self.conn.execute(command_builder("webExtension.install", params))
return result
except WebDriverException as e:
if "Method not available" in str(e):
raise WebDriverException(
f"{str(e)}. If you are using Chrome or Edge, add '--enable-unsafe-extension-debugging' "
"and '--remote-debugging-pipe' arguments or set options.enable_webextensions = True"
) from e
raise

def uninstall(self, extension_id_or_result: Union[str, dict]) -> None:
"""Uninstalls a web extension from the remote end.
Expand Down
168 changes: 111 additions & 57 deletions py/test/selenium/webdriver/common/bidi_webextension_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@

import base64
import os
import shutil
import tempfile

import pytest
from python.runfiles import Runfiles

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait

Expand Down Expand Up @@ -62,62 +65,113 @@ def test_webextension_initialized(driver):

@pytest.mark.xfail_chrome
@pytest.mark.xfail_edge
def test_install_extension_path(driver, pages):
"""Test installing an extension from a directory path."""
path = os.path.join(extensions, EXTENSION_PATH)
class TestFirefoxWebExtension:
"""Firefox-specific WebExtension tests."""

ext_info = install_extension(driver, path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)
def test_install_extension_path(self, driver, pages):
"""Test installing an extension from a directory path."""


@pytest.mark.xfail_chrome
@pytest.mark.xfail_edge
def test_install_archive_extension_path(driver, pages):
"""Test installing an extension from an archive path."""
path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)

ext_info = install_extension(driver, archive_path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)


@pytest.mark.xfail_chrome
@pytest.mark.xfail_edge
def test_install_base64_extension_path(driver, pages):
"""Test installing an extension from a base64 encoded string."""
path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)

with open(path, "rb") as file:
base64_encoded = base64.b64encode(file.read()).decode("utf-8")

ext_info = install_extension(driver, base64_value=base64_encoded)

# TODO: the extension is installed but the script is not injected, check and fix
# verify_extension_injection(driver, pages)

uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)


@pytest.mark.xfail_chrome
@pytest.mark.xfail_edge
def test_install_unsigned_extension(driver, pages):
"""Test installing an unsigned extension."""
path = os.path.join(extensions, "webextensions-selenium-example")

ext_info = install_extension(driver, path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)


@pytest.mark.xfail_chrome
@pytest.mark.xfail_edge
def test_install_with_extension_id_uninstall(driver, pages):
"""Test uninstalling an extension using just the extension ID."""
path = os.path.join(extensions, EXTENSION_PATH)

ext_info = install_extension(driver, path=path)
extension_id = ext_info.get("extension")

# Uninstall using the extension ID
uninstall_extension_and_verify_extension_uninstalled(driver, extension_id)
path = os.path.join(extensions, EXTENSION_PATH)
ext_info = install_extension(driver, path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)

def test_install_archive_extension_path(self, driver, pages):
"""Test installing an extension from an archive path."""

path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)
ext_info = install_extension(driver, archive_path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)

def test_install_base64_extension_path(self, driver, pages):
"""Test installing an extension from a base64 encoded string."""

path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)
with open(path, "rb") as file:
base64_encoded = base64.b64encode(file.read()).decode("utf-8")
ext_info = install_extension(driver, base64_value=base64_encoded)
# TODO: the extension is installed but the script is not injected, check and fix
# verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)

def test_install_unsigned_extension(self, driver, pages):
"""Test installing an unsigned extension."""

path = os.path.join(extensions, "webextensions-selenium-example")
ext_info = install_extension(driver, path=path)
verify_extension_injection(driver, pages)
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)

def test_install_with_extension_id_uninstall(self, driver, pages):
"""Test uninstalling an extension using just the extension ID."""

path = os.path.join(extensions, EXTENSION_PATH)
ext_info = install_extension(driver, path=path)
extension_id = ext_info.get("extension")
# Uninstall using the extension ID
uninstall_extension_and_verify_extension_uninstalled(driver, extension_id)


@pytest.mark.xfail_firefox
class TestChromiumWebExtension:
"""Chrome/Edge-specific WebExtension tests with custom driver."""

@pytest.fixture
def pages_chromium(self, webserver, chromium_driver):
class Pages:
def load(self, name):
chromium_driver.get(webserver.where_is(name, localhost=False))

return Pages()

@pytest.fixture
def chromium_driver(self, chromium_options, request):
"""Create a Chrome/Edge driver with webextension support enabled."""
driver_option = request.config.option.drivers[0].lower()

if driver_option == "chrome":
browser_class = webdriver.Chrome
elif driver_option == "edge":
browser_class = webdriver.Edge

temp_dir = tempfile.mkdtemp(prefix="chrome-profile-")

chromium_options.enable_bidi = True
chromium_options.enable_webextensions = True
chromium_options.add_argument(f"--user-data-dir={temp_dir}")
chromium_options.add_argument("--no-sandbox")
chromium_options.add_argument("--disable-dev-shm-usage")

chromium_driver = browser_class(options=chromium_options)

yield chromium_driver
chromium_driver.quit()

# delete the temp directory
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)

def test_install_extension_path(self, chromium_driver, pages_chromium):
"""Test installing an extension from a directory path."""
path = os.path.join(extensions, EXTENSION_PATH)
ext_info = chromium_driver.webextension.install(path=path)

verify_extension_injection(chromium_driver, pages_chromium)
uninstall_extension_and_verify_extension_uninstalled(chromium_driver, ext_info)

def test_install_unsigned_extension(self, chromium_driver, pages_chromium):
"""Test installing an unsigned extension."""
path = os.path.join(extensions, "webextensions-selenium-example")
ext_info = chromium_driver.webextension.install(path=path)

verify_extension_injection(chromium_driver, pages_chromium)
uninstall_extension_and_verify_extension_uninstalled(chromium_driver, ext_info)

def test_install_with_extension_id_uninstall(self, chromium_driver):
"""Test uninstalling an extension using just the extension ID."""
path = os.path.join(extensions, EXTENSION_PATH)
ext_info = chromium_driver.webextension.install(path=path)
extension_id = ext_info.get("extension")
# Uninstall using the extension ID
uninstall_extension_and_verify_extension_uninstalled(chromium_driver, extension_id)