Skip to content

Commit 43e6bb9

Browse files
navin772cgoldberg
andauthored
[py][bidi]: Add bidi webExtension module (#15749)
* add bidi webextension module * add tests * use bazel runfiles * add webextension in api docs --------- Co-authored-by: Corey Goldberg <[email protected]>
1 parent ef05c15 commit 43e6bb9

File tree

7 files changed

+250
-2
lines changed

7 files changed

+250
-2
lines changed

common/extensions/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ exports_files(
3232
"webextensions-selenium-example.xpi",
3333
"webextensions-selenium-example.zip",
3434
"webextensions-selenium-example-unsigned.zip",
35+
"webextensions-selenium-example.crx",
36+
"webextensions-selenium-example",
37+
"webextensions-selenium-example-signed",
3538
],
3639
visibility = [
3740
"//java/test/org/openqa/selenium/firefox:__pkg__",

py/BUILD.bazel

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
load("@aspect_bazel_lib//lib:copy_directory.bzl", "copy_directory")
12
load("@aspect_rules_lint//format:defs.bzl", "format_multirun")
23
load("@bazel_skylib//rules:select_file.bzl", "select_file")
34
load("@py_dev_requirements//:requirements.bzl", "requirement")
@@ -96,6 +97,7 @@ TEST_DEPS = [
9697
requirement("sortedcontainers"),
9798
requirement("sniffio"),
9899
requirement("zipp"),
100+
"@rules_python//python/runfiles",
99101
]
100102

101103
copy_file(
@@ -164,6 +166,24 @@ copy_file(
164166
out = "test/extensions/webextensions-selenium-example-unsigned.zip",
165167
)
166168

169+
copy_file(
170+
name = "webextensions-selenium-example-crx",
171+
src = "//common/extensions:webextensions-selenium-example.crx",
172+
out = "test/extensions/webextensions-selenium-example.crx",
173+
)
174+
175+
copy_directory(
176+
name = "webextensions-selenium-example-dir",
177+
src = "//common/extensions:webextensions-selenium-example",
178+
out = "test/extensions/webextensions-selenium-example",
179+
)
180+
181+
copy_directory(
182+
name = "webextensions-selenium-example-signed-dir",
183+
src = "//common/extensions:webextensions-selenium-example-signed",
184+
out = "test/extensions/webextensions-selenium-example-signed",
185+
)
186+
167187
select_file(
168188
name = "global-license",
169189
srcs = "//:license",
@@ -339,6 +359,9 @@ py_library(
339359
"pyproject.toml",
340360
"test/selenium/webdriver/common/test_file.txt",
341361
"test/selenium/webdriver/common/test_file2.txt",
362+
":webextensions-selenium-example-crx",
363+
":webextensions-selenium-example-dir",
364+
":webextensions-selenium-example-signed-dir",
342365
":webextensions-selenium-example-unsigned-zip",
343366
":webextensions-selenium-example-xpi",
344367
":webextensions-selenium-example-zip",

py/docs/source/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Webdriver.common
3939
selenium.webdriver.common.bidi.network
4040
selenium.webdriver.common.bidi.script
4141
selenium.webdriver.common.bidi.session
42+
selenium.webdriver.common.bidi.webextension
4243
selenium.webdriver.common.by
4344
selenium.webdriver.common.desired_capabilities
4445
selenium.webdriver.common.driver_finder
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from typing import Dict, Union
19+
20+
from selenium.webdriver.common.bidi.common import command_builder
21+
22+
23+
class WebExtension:
24+
"""
25+
BiDi implementation of the webExtension module.
26+
"""
27+
28+
def __init__(self, conn):
29+
self.conn = conn
30+
31+
def install(self, path=None, archive_path=None, base64_value=None) -> Dict:
32+
"""Installs a web extension in the remote end.
33+
34+
You must provide exactly one of the parameters.
35+
36+
Parameters:
37+
-----------
38+
path: Path to an extension directory
39+
archive_path: Path to an extension archive file
40+
base64_value: Base64 encoded string of the extension archive
41+
42+
Returns:
43+
-------
44+
Dict: A dictionary containing the extension ID.
45+
"""
46+
if sum(x is not None for x in (path, archive_path, base64_value)) != 1:
47+
raise ValueError("Exactly one of path, archive_path, or base64_value must be provided")
48+
49+
if path is not None:
50+
extension_data = {"type": "path", "path": path}
51+
elif archive_path is not None:
52+
extension_data = {"type": "archivePath", "path": archive_path}
53+
elif base64_value is not None:
54+
extension_data = {"type": "base64", "value": base64_value}
55+
56+
params = {"extensionData": extension_data}
57+
result = self.conn.execute(command_builder("webExtension.install", params))
58+
return result
59+
60+
def uninstall(self, extension_id_or_result: Union[str, Dict]) -> None:
61+
"""Uninstalls a web extension from the remote end.
62+
63+
Parameters:
64+
-----------
65+
extension_id_or_result: Either the extension ID as a string or the result dictionary
66+
from a previous install() call containing the extension ID.
67+
"""
68+
if isinstance(extension_id_or_result, dict):
69+
extension_id = extension_id_or_result.get("extension")
70+
else:
71+
extension_id = extension_id_or_result
72+
73+
params = {"extension": extension_id}
74+
self.conn.execute(command_builder("webExtension.uninstall", params))

py/selenium/webdriver/remote/webdriver.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from selenium.webdriver.common.bidi.script import Script
4646
from selenium.webdriver.common.bidi.session import Session
4747
from selenium.webdriver.common.bidi.storage import Storage
48+
from selenium.webdriver.common.bidi.webextension import WebExtension
4849
from selenium.webdriver.common.by import By
4950
from selenium.webdriver.common.options import ArgOptions, BaseOptions
5051
from selenium.webdriver.common.print_page_options import PrintOptions
@@ -263,6 +264,7 @@ def __init__(
263264
self._bidi_session = None
264265
self._browsing_context = None
265266
self._storage = None
267+
self._webextension = None
266268

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

13381340
return self._storage
13391341

1342+
@property
1343+
def webextension(self):
1344+
"""Returns a webextension module object for BiDi webextension commands.
1345+
1346+
Returns:
1347+
--------
1348+
WebExtension: an object containing access to BiDi webextension commands.
1349+
1350+
Examples:
1351+
---------
1352+
>>> extension_path = "/path/to/extension"
1353+
>>> extension_result = driver.webextension.install(path=extension_path)
1354+
>>> driver.webextension.uninstall(extension_result)
1355+
"""
1356+
if not self._websocket_connection:
1357+
self._start_bidi()
1358+
1359+
if self._webextension is None:
1360+
self._webextension = WebExtension(self._websocket_connection)
1361+
1362+
return self._webextension
1363+
13401364
def _get_cdp_details(self):
13411365
import json
13421366

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Licensed to the Software Freedom Conservancy (SFC) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The SFC licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import base64
19+
import os
20+
import pytest
21+
22+
from python.runfiles import Runfiles
23+
from selenium.webdriver.common.by import By
24+
from selenium.webdriver.support.wait import WebDriverWait
25+
26+
27+
EXTENSION_ID = "[email protected]"
28+
EXTENSION_PATH = "webextensions-selenium-example-signed"
29+
EXTENSION_ARCHIVE_PATH = "webextensions-selenium-example.xpi"
30+
31+
# Use bazel Runfiles to locate the test extension directory
32+
r = Runfiles.Create()
33+
extensions = r.Rlocation("selenium/py/test/extensions")
34+
35+
36+
def install_extension(driver, **kwargs):
37+
result = driver.webextension.install(**kwargs)
38+
assert result.get("extension") == EXTENSION_ID
39+
return result
40+
41+
42+
def verify_extension_injection(driver, pages):
43+
pages.load("blank.html")
44+
injected = WebDriverWait(driver, timeout=2).until(
45+
lambda dr: dr.find_element(By.ID, "webextensions-selenium-example")
46+
)
47+
assert injected.text == "Content injected by webextensions-selenium-example"
48+
49+
50+
def uninstall_extension_and_verify_extension_uninstalled(driver, extension_info):
51+
driver.webextension.uninstall(extension_info)
52+
53+
context_id = driver.current_window_handle
54+
driver.browsing_context.reload(context_id)
55+
assert len(driver.find_elements(By.ID, "webextensions-selenium-example")) == 0
56+
57+
58+
def test_webextension_initialized(driver):
59+
"""Test that the webextension module is initialized properly."""
60+
assert driver.webextension is not None
61+
62+
63+
@pytest.mark.xfail_chrome
64+
@pytest.mark.xfail_edge
65+
def test_install_extension_path(driver, pages):
66+
"""Test installing an extension from a directory path."""
67+
path = os.path.join(extensions, EXTENSION_PATH)
68+
69+
ext_info = install_extension(driver, path=path)
70+
verify_extension_injection(driver, pages)
71+
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)
72+
73+
74+
@pytest.mark.xfail_chrome
75+
@pytest.mark.xfail_edge
76+
def test_install_archive_extension_path(driver, pages):
77+
"""Test installing an extension from an archive path."""
78+
path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)
79+
80+
ext_info = install_extension(driver, archive_path=path)
81+
verify_extension_injection(driver, pages)
82+
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)
83+
84+
85+
@pytest.mark.xfail_chrome
86+
@pytest.mark.xfail_edge
87+
def test_install_base64_extension_path(driver, pages):
88+
"""Test installing an extension from a base64 encoded string."""
89+
path = os.path.join(extensions, EXTENSION_ARCHIVE_PATH)
90+
91+
with open(path, "rb") as file:
92+
base64_encoded = base64.b64encode(file.read()).decode("utf-8")
93+
94+
ext_info = install_extension(driver, base64_value=base64_encoded)
95+
96+
# TODO: the extension is installed but the script is not injected, check and fix
97+
# verify_extension_injection(driver, pages)
98+
99+
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)
100+
101+
102+
@pytest.mark.xfail_chrome
103+
@pytest.mark.xfail_edge
104+
def test_install_unsigned_extension(driver, pages):
105+
"""Test installing an unsigned extension."""
106+
path = os.path.join(extensions, "webextensions-selenium-example")
107+
108+
ext_info = install_extension(driver, path=path)
109+
verify_extension_injection(driver, pages)
110+
uninstall_extension_and_verify_extension_uninstalled(driver, ext_info)
111+
112+
113+
@pytest.mark.xfail_chrome
114+
@pytest.mark.xfail_edge
115+
def test_install_with_extension_id_uninstall(driver, pages):
116+
"""Test uninstalling an extension using just the extension ID."""
117+
path = os.path.join(extensions, EXTENSION_PATH)
118+
119+
ext_info = install_extension(driver, path=path)
120+
extension_id = ext_info.get("extension")
121+
122+
# Uninstall using the extension ID
123+
uninstall_extension_and_verify_extension_uninstalled(driver, extension_id)

py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def test_install_uninstall_unsigned_addon_zip(driver, pages):
7878
def test_install_uninstall_signed_addon_dir(driver, pages):
7979
zip = os.path.join(extensions, "webextensions-selenium-example.zip")
8080

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

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

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

0 commit comments

Comments
 (0)