diff --git a/common/extensions/BUILD.bazel b/common/extensions/BUILD.bazel index 8dda5be92e7ec..9095b311e4fb3 100644 --- a/common/extensions/BUILD.bazel +++ b/common/extensions/BUILD.bazel @@ -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__", diff --git a/py/BUILD.bazel b/py/BUILD.bazel index 46438431de732..db45593788f2b 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -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") @@ -96,6 +97,7 @@ TEST_DEPS = [ requirement("sortedcontainers"), requirement("sniffio"), requirement("zipp"), + "@rules_python//python/runfiles", ] copy_file( @@ -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", @@ -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", diff --git a/py/docs/source/api.rst b/py/docs/source/api.rst index dc34be77815db..11c5f3b098c77 100644 --- a/py/docs/source/api.rst +++ b/py/docs/source/api.rst @@ -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 diff --git a/py/selenium/webdriver/common/bidi/webextension.py b/py/selenium/webdriver/common/bidi/webextension.py new file mode 100644 index 0000000000000..2e42de34c3ce5 --- /dev/null +++ b/py/selenium/webdriver/common/bidi/webextension.py @@ -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)) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 01530a05b1708..14c8e6555f918 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -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 @@ -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}")>' @@ -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 diff --git a/py/test/selenium/webdriver/common/bidi_webextension_tests.py b/py/test/selenium/webdriver/common/bidi_webextension_tests.py new file mode 100644 index 0000000000000..b487eb370336f --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi_webextension_tests.py @@ -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 = "webextensions-selenium-example-v3@example.com" +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) diff --git a/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py b/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py index 1852adb4ddb82..c7cb6797cd066 100644 --- a/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py +++ b/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py @@ -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) @@ -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)