Skip to content

Commit d973939

Browse files
committed
[py] Implement script module for BiDi
The commit also generates Bazel test targets for BiDi-backed implementation
1 parent 8ae6bea commit d973939

File tree

10 files changed

+398
-20
lines changed

10 files changed

+398
-20
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ py/selenium/webdriver/remote/isDisplayed.js
7676
py/docs/build/
7777
py/build/
7878
py/LICENSE
79+
py/pytestdebug.log
7980
selenium.egg-info/
8081
third_party/java/jetty/jetty-repacked.jar
8182
*.user

py/BUILD.bazel

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ py_package(
214214
"py.selenium.webdriver.chrome",
215215
"py.selenium.webdriver.chromium",
216216
"py.selenium.webdriver.common",
217+
"py.selenium.webdriver.common.bidi",
217218
"py.selenium.webdriver.common.devtools",
218219
"py.selenium.webdriver.edge",
219220
"py.selenium.webdriver.firefox",
@@ -380,10 +381,39 @@ py_library(
380381
deps = [],
381382
)
382383

384+
BIDI_TESTS = glob(["test/selenium/webdriver/common/**/*bidi*_tests.py"])
385+
383386
[
384387
py_test_suite(
385388
name = "common-%s" % browser,
386389
size = "large",
390+
srcs = glob(
391+
[
392+
"test/selenium/webdriver/common/**/*.py",
393+
"test/selenium/webdriver/support/**/*.py",
394+
],
395+
exclude = BIDI_TESTS + ["test/selenium/webdriver/common/print_pdf_tests.py"],
396+
),
397+
args = [
398+
"--instafail",
399+
"--bidi=false",
400+
] + BROWSERS[browser]["args"],
401+
data = BROWSERS[browser]["data"],
402+
env_inherit = ["DISPLAY"],
403+
tags = ["no-sandbox"] + BROWSERS[browser]["tags"],
404+
deps = [
405+
":init-tree",
406+
":selenium",
407+
":webserver",
408+
] + TEST_DEPS,
409+
)
410+
for browser in BROWSERS.keys()
411+
]
412+
413+
[
414+
py_test_suite(
415+
name = "common-%s-bidi" % browser,
416+
size = "large",
387417
srcs = glob(
388418
[
389419
"test/selenium/webdriver/common/**/*.py",
@@ -393,12 +423,11 @@ py_library(
393423
),
394424
args = [
395425
"--instafail",
426+
"--bidi=true",
396427
] + BROWSERS[browser]["args"],
397428
data = BROWSERS[browser]["data"],
398429
env_inherit = ["DISPLAY"],
399-
tags = [
400-
"no-sandbox",
401-
] + BROWSERS[browser]["tags"],
430+
tags = ["no-sandbox"] + BROWSERS[browser]["tags"],
402431
deps = [
403432
":init-tree",
404433
":selenium",
@@ -504,10 +533,13 @@ py_test_suite(
504533
py_test_suite(
505534
name = "test-remote",
506535
size = "large",
507-
srcs = glob([
508-
"test/selenium/webdriver/common/**/*.py",
509-
"test/selenium/webdriver/support/**/*.py",
510-
]),
536+
srcs = glob(
537+
[
538+
"test/selenium/webdriver/common/**/*.py",
539+
"test/selenium/webdriver/support/**/*.py",
540+
],
541+
exclude = BIDI_TESTS,
542+
),
511543
args = [
512544
"--instafail",
513545
"--driver=remote",

py/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ def pytest_addoption(parser):
7979
dest="use_lan_ip",
8080
help="Whether to start test server with lan ip instead of localhost",
8181
)
82+
parser.addoption(
83+
"--bidi",
84+
action="store",
85+
dest="bidi",
86+
metavar="BIDI",
87+
help="Whether to enable BiDi support",
88+
)
8289

8390

8491
def pytest_ignore_collect(path, config):
@@ -158,6 +165,20 @@ def fin():
158165
driver_instance = getattr(webdriver, driver_class)(**kwargs)
159166
yield driver_instance
160167

168+
# Close the browser after BiDi tests. Those make event subscriptions
169+
# and doesn't seems to be stable enough, causing the flakiness of the
170+
# subsequent tests.
171+
# Remove this when BiDi implementation and API is stable.
172+
if bool(request.config.option.bidi):
173+
174+
def fin():
175+
global driver_instance
176+
if driver_instance is not None:
177+
driver_instance.quit()
178+
driver_instance = None
179+
180+
request.addfinalizer(fin)
181+
161182
if request.node.get_closest_marker("no_driver_after_test"):
162183
driver_instance = None
163184

@@ -166,6 +187,7 @@ def get_options(driver_class, config):
166187
browser_path = config.option.binary
167188
browser_args = config.option.args
168189
headless = bool(config.option.headless)
190+
bidi = bool(config.option.bidi)
169191
options = None
170192

171193
if browser_path or browser_args:
@@ -187,6 +209,13 @@ def get_options(driver_class, config):
187209
options.add_argument("--headless=new")
188210
if driver_class == "Firefox":
189211
options.add_argument("-headless")
212+
213+
if bidi:
214+
if not options:
215+
options = getattr(webdriver, f"{driver_class}Options")()
216+
217+
options.web_socket_url = True
218+
190219
return options
191220

192221

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 typing
19+
from dataclasses import dataclass
20+
21+
from .session import session_subscribe
22+
from .session import session_unsubscribe
23+
24+
25+
class Script:
26+
def __init__(self, conn):
27+
self.conn = conn
28+
self.log_entry_subscribed = False
29+
30+
def add_console_message_handler(self, handler):
31+
self._subscribe_to_log_entries()
32+
return self.conn.add_callback(LogEntryAdded, self._handle_log_entry("console", handler))
33+
34+
def add_javascript_error_handler(self, handler):
35+
self._subscribe_to_log_entries()
36+
return self.conn.add_callback(LogEntryAdded, self._handle_log_entry("javascript", handler))
37+
38+
def remove_console_message_handler(self, id):
39+
self.conn.remove_callback(LogEntryAdded, id)
40+
self._unsubscribe_from_log_entries()
41+
42+
remove_javascript_error_handler = remove_console_message_handler
43+
44+
def _subscribe_to_log_entries(self):
45+
if not self.log_entry_subscribed:
46+
self.conn.execute(session_subscribe(LogEntryAdded.event_class))
47+
self.log_entry_subscribed = True
48+
49+
def _unsubscribe_from_log_entries(self):
50+
if self.log_entry_subscribed and LogEntryAdded.event_class not in self.conn.callbacks:
51+
self.conn.execute(session_unsubscribe(LogEntryAdded.event_class))
52+
self.log_entry_subscribed = False
53+
54+
def _handle_log_entry(self, type, handler):
55+
def _handle_log_entry(log_entry):
56+
if log_entry.type_ == type:
57+
handler(log_entry)
58+
59+
return _handle_log_entry
60+
61+
62+
class LogEntryAdded:
63+
event_class = "log.entryAdded"
64+
65+
@classmethod
66+
def from_json(cls, json):
67+
print(json)
68+
if json["type"] == "console":
69+
return ConsoleLogEntry.from_json(json)
70+
elif json["type"] == "javascript":
71+
return JavaScriptLogEntry.from_json(json)
72+
73+
74+
@dataclass
75+
class ConsoleLogEntry:
76+
level: str
77+
text: str
78+
timestamp: str
79+
method: str
80+
args: typing.List[dict]
81+
type_: str
82+
83+
@classmethod
84+
def from_json(cls, json):
85+
return cls(
86+
level=json["level"],
87+
text=json["text"],
88+
timestamp=json["timestamp"],
89+
method=json["method"],
90+
args=json["args"],
91+
type_=json["type"],
92+
)
93+
94+
95+
@dataclass
96+
class JavaScriptLogEntry:
97+
level: str
98+
text: str
99+
timestamp: str
100+
stacktrace: dict
101+
type_: str
102+
103+
@classmethod
104+
def from_json(cls, json):
105+
return cls(
106+
level=json["level"],
107+
text=json["text"],
108+
timestamp=json["timestamp"],
109+
stacktrace=json["stackTrace"],
110+
type_=json["type"],
111+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
19+
def session_subscribe(*events, browsing_contexts=[]):
20+
cmd_dict = {
21+
"method": "session.subscribe",
22+
"params": {
23+
"events": events,
24+
},
25+
}
26+
if browsing_contexts:
27+
cmd_dict["params"]["browsingContexts"] = browsing_contexts
28+
_ = yield cmd_dict
29+
return None
30+
31+
32+
def session_unsubscribe(*events, browsing_contexts=[]):
33+
cmd_dict = {
34+
"method": "session.unsubscribe",
35+
"params": {
36+
"events": events,
37+
},
38+
}
39+
if browsing_contexts:
40+
cmd_dict["params"]["browsingContexts"] = browsing_contexts
41+
_ = yield cmd_dict
42+
return None

py/selenium/webdriver/common/options.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,28 @@ class BaseOptions(metaclass=ABCMeta):
416416
- `None`
417417
"""
418418

419+
web_socket_url = _BaseOptionsDescriptor("webSocketUrl")
420+
"""Gets and Sets WebSocket URL.
421+
422+
Usage
423+
-----
424+
- Get
425+
- `self.web_socket_url`
426+
- Set
427+
- `self.web_socket_url` = `value`
428+
429+
Parameters
430+
----------
431+
`value`: `bool`
432+
433+
Returns
434+
-------
435+
- Get
436+
- `bool`
437+
- Set
438+
- `None`
439+
"""
440+
419441
def __init__(self) -> None:
420442
super().__init__()
421443
self._caps = self.default_capabilities

py/selenium/webdriver/remote/webdriver.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from selenium.common.exceptions import NoSuchCookieException
4242
from selenium.common.exceptions import NoSuchElementException
4343
from selenium.common.exceptions import WebDriverException
44+
from selenium.webdriver.common.bidi.script import Script
4445
from selenium.webdriver.common.by import By
4546
from selenium.webdriver.common.options import BaseOptions
4647
from selenium.webdriver.common.print_page_options import PrintOptions
@@ -209,7 +210,9 @@ def __init__(
209210
self._authenticator_id = None
210211
self.start_client()
211212
self.start_session(capabilities)
213+
212214
self._websocket_connection = None
215+
self._script = None
213216

214217
def __repr__(self):
215218
return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>'
@@ -1067,6 +1070,24 @@ async def bidi_connection(self):
10671070
async with conn.open_session(target_id) as session:
10681071
yield BidiConnection(session, cdp, devtools)
10691072

1073+
@property
1074+
def script(self):
1075+
if not self._websocket_connection:
1076+
self._start_bidi()
1077+
1078+
if not self._script:
1079+
self._script = Script(self._websocket_connection)
1080+
1081+
return self._script
1082+
1083+
def _start_bidi(self):
1084+
if self.caps.get("webSocketUrl"):
1085+
ws_url = self.caps.get("webSocketUrl")
1086+
else:
1087+
raise WebDriverException("Unable to find url to connect to from capabilities")
1088+
1089+
self._websocket_connection = WebSocketConnection(ws_url)
1090+
10701091
def _get_cdp_details(self):
10711092
import json
10721093

0 commit comments

Comments
 (0)