Skip to content

[py][bidi]: add bidi webExtension module #15749

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 15 commits into from
May 22, 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
3 changes: 3 additions & 0 deletions common/extensions/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ exports_files(
"webextensions-selenium-example.xpi",
"webextensions-selenium-example.zip",
"webextensions-selenium-example-unsigned.zip",
"webextensions-selenium-example.crx",
"webextensions-selenium-example",
"webextensions-selenium-example-signed",
],
visibility = [
"//java/test/org/openqa/selenium/firefox:__pkg__",
Expand Down
23 changes: 23 additions & 0 deletions py/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
load("@aspect_bazel_lib//lib:copy_directory.bzl", "copy_directory")
load("@aspect_rules_lint//format:defs.bzl", "format_multirun")
load("@bazel_skylib//rules:select_file.bzl", "select_file")
load("@py_dev_requirements//:requirements.bzl", "requirement")
Expand Down Expand Up @@ -96,6 +97,7 @@ TEST_DEPS = [
requirement("sortedcontainers"),
requirement("sniffio"),
requirement("zipp"),
"@rules_python//python/runfiles",
]

copy_file(
Expand Down Expand Up @@ -164,6 +166,24 @@ copy_file(
out = "test/extensions/webextensions-selenium-example-unsigned.zip",
)

copy_file(
name = "webextensions-selenium-example-crx",
src = "//common/extensions:webextensions-selenium-example.crx",
out = "test/extensions/webextensions-selenium-example.crx",
)

copy_directory(
name = "webextensions-selenium-example-dir",
src = "//common/extensions:webextensions-selenium-example",
out = "test/extensions/webextensions-selenium-example",
)

copy_directory(
name = "webextensions-selenium-example-signed-dir",
src = "//common/extensions:webextensions-selenium-example-signed",
out = "test/extensions/webextensions-selenium-example-signed",
)

select_file(
name = "global-license",
srcs = "//:license",
Expand Down Expand Up @@ -339,6 +359,9 @@ py_library(
"pyproject.toml",
"test/selenium/webdriver/common/test_file.txt",
"test/selenium/webdriver/common/test_file2.txt",
":webextensions-selenium-example-crx",
":webextensions-selenium-example-dir",
":webextensions-selenium-example-signed-dir",
":webextensions-selenium-example-unsigned-zip",
":webextensions-selenium-example-xpi",
":webextensions-selenium-example-zip",
Expand Down
1 change: 1 addition & 0 deletions py/docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Webdriver.common
selenium.webdriver.common.bidi.network
selenium.webdriver.common.bidi.script
selenium.webdriver.common.bidi.session
selenium.webdriver.common.bidi.webextension
selenium.webdriver.common.by
selenium.webdriver.common.desired_capabilities
selenium.webdriver.common.driver_finder
Expand Down
74 changes: 74 additions & 0 deletions py/selenium/webdriver/common/bidi/webextension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

from typing import Dict, Union

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


class WebExtension:
"""
BiDi implementation of the webExtension module.
"""

def __init__(self, conn):
self.conn = conn

def install(self, path=None, archive_path=None, base64_value=None) -> Dict:
"""Installs a web extension in the remote end.
You must provide exactly one of the parameters.
Parameters:
-----------
path: Path to an extension directory
archive_path: Path to an extension archive file
base64_value: Base64 encoded string of the extension archive
Returns:
-------
Dict: A dictionary containing the extension ID.
"""
if sum(x is not None for x in (path, archive_path, base64_value)) != 1:
raise ValueError("Exactly one of path, archive_path, or base64_value must be provided")

if path is not None:
extension_data = {"type": "path", "path": path}
elif archive_path is not None:
extension_data = {"type": "archivePath", "path": archive_path}
elif base64_value is not None:
extension_data = {"type": "base64", "value": base64_value}

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

def uninstall(self, extension_id_or_result: Union[str, Dict]) -> None:
"""Uninstalls a web extension from the remote end.
Parameters:
-----------
extension_id_or_result: Either the extension ID as a string or the result dictionary
from a previous install() call containing the extension ID.
"""
if isinstance(extension_id_or_result, dict):
extension_id = extension_id_or_result.get("extension")
else:
extension_id = extension_id_or_result

params = {"extension": extension_id}
self.conn.execute(command_builder("webExtension.uninstall", params))
24 changes: 24 additions & 0 deletions py/selenium/webdriver/remote/webdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from selenium.webdriver.common.bidi.script import Script
from selenium.webdriver.common.bidi.session import Session
from selenium.webdriver.common.bidi.storage import Storage
from selenium.webdriver.common.bidi.webextension import WebExtension
from selenium.webdriver.common.by import By
from selenium.webdriver.common.options import ArgOptions, BaseOptions
from selenium.webdriver.common.print_page_options import PrintOptions
Expand Down Expand Up @@ -263,6 +264,7 @@ def __init__(
self._bidi_session = None
self._browsing_context = None
self._storage = None
self._webextension = None

def __repr__(self):
return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>'
Expand Down Expand Up @@ -1337,6 +1339,28 @@ def storage(self):

return self._storage

@property
def webextension(self):
"""Returns a webextension module object for BiDi webextension commands.

Returns:
--------
WebExtension: an object containing access to BiDi webextension commands.

Examples:
---------
>>> extension_path = "/path/to/extension"
>>> extension_result = driver.webextension.install(path=extension_path)
>>> driver.webextension.uninstall(extension_result)
"""
if not self._websocket_connection:
self._start_bidi()

if self._webextension is None:
self._webextension = WebExtension(self._websocket_connection)

return self._webextension

def _get_cdp_details(self):
import json

Expand Down
123 changes: 123 additions & 0 deletions py/test/selenium/webdriver/common/bidi_webextension_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

import base64
import os
import pytest

from python.runfiles import Runfiles
from selenium.webdriver.common.by import By
from selenium.webdriver.support.wait import WebDriverWait


EXTENSION_ID = "[email protected]"
EXTENSION_PATH = "webextensions-selenium-example-signed"
EXTENSION_ARCHIVE_PATH = "webextensions-selenium-example.xpi"

# Use bazel Runfiles to locate the test extension directory
r = Runfiles.Create()
extensions = r.Rlocation("selenium/py/test/extensions")


def install_extension(driver, **kwargs):
result = driver.webextension.install(**kwargs)
assert result.get("extension") == EXTENSION_ID
return result


def verify_extension_injection(driver, pages):
pages.load("blank.html")
injected = WebDriverWait(driver, timeout=2).until(
lambda dr: dr.find_element(By.ID, "webextensions-selenium-example")
)
assert injected.text == "Content injected by webextensions-selenium-example"


def uninstall_extension_and_verify_extension_uninstalled(driver, extension_info):
driver.webextension.uninstall(extension_info)

context_id = driver.current_window_handle
driver.browsing_context.reload(context_id)
assert len(driver.find_elements(By.ID, "webextensions-selenium-example")) == 0


def test_webextension_initialized(driver):
"""Test that the webextension module is initialized properly."""
assert driver.webextension is not None


@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)

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_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)
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def test_install_uninstall_unsigned_addon_zip(driver, pages):
def test_install_uninstall_signed_addon_dir(driver, pages):
zip = os.path.join(extensions, "webextensions-selenium-example.zip")

target = os.path.join(extensions, "webextensions-selenium-example")
target = os.path.join(extensions, "webextensions-selenium-example-unzip")
with zipfile.ZipFile(zip, "r") as zip_ref:
zip_ref.extractall(target)

Expand All @@ -98,7 +98,7 @@ def test_install_uninstall_signed_addon_dir(driver, pages):

def test_install_uninstall_unsigned_addon_dir(driver, pages):
zip = os.path.join(extensions, "webextensions-selenium-example-unsigned.zip")
target = os.path.join(extensions, "webextensions-selenium-example-unsigned")
target = os.path.join(extensions, "webextensions-selenium-example-unsigned-unzip")
with zipfile.ZipFile(zip, "r") as zip_ref:
zip_ref.extractall(target)

Expand Down
Loading