Skip to content

Commit 963bf95

Browse files
navin772shbenzer
andauthored
[py][bidi]: Implement BiDi browser module (#15616)
* implement BiDi browser module * add test browser tests * run `format.sh` * move `command_builder` to `common.py` to avoid function duplication * add `init.py` * move bidi test to `bidi/` directory * fix tests * check user_context_id for default and raise exception * revert tests relocation since CI is failing --------- Co-authored-by: Simon Benzer <[email protected]>
1 parent a187e3c commit 963bf95

File tree

5 files changed

+372
-33
lines changed

5 files changed

+372
-33
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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
19+
from typing import List
20+
21+
from selenium.webdriver.common.bidi.common import command_builder
22+
23+
24+
class ClientWindowState:
25+
"""Represents a window state."""
26+
27+
FULLSCREEN = "fullscreen"
28+
MAXIMIZED = "maximized"
29+
MINIMIZED = "minimized"
30+
NORMAL = "normal"
31+
32+
33+
class ClientWindowInfo:
34+
"""Represents a client window information."""
35+
36+
def __init__(
37+
self,
38+
client_window: str,
39+
state: str,
40+
width: int,
41+
height: int,
42+
x: int,
43+
y: int,
44+
active: bool,
45+
):
46+
self.client_window = client_window
47+
self.state = state
48+
self.width = width
49+
self.height = height
50+
self.x = x
51+
self.y = y
52+
self.active = active
53+
54+
def get_state(self) -> str:
55+
"""Gets the state of the client window.
56+
57+
Returns:
58+
-------
59+
str: The state of the client window (one of the ClientWindowState constants).
60+
"""
61+
return self.state
62+
63+
def get_client_window(self) -> str:
64+
"""Gets the client window identifier.
65+
66+
Returns:
67+
-------
68+
str: The client window identifier.
69+
"""
70+
return self.client_window
71+
72+
def get_width(self) -> int:
73+
"""Gets the width of the client window.
74+
75+
Returns:
76+
-------
77+
int: The width of the client window.
78+
"""
79+
return self.width
80+
81+
def get_height(self) -> int:
82+
"""Gets the height of the client window.
83+
84+
Returns:
85+
-------
86+
int: The height of the client window.
87+
"""
88+
return self.height
89+
90+
def get_x(self) -> int:
91+
"""Gets the x coordinate of the client window.
92+
93+
Returns:
94+
-------
95+
int: The x coordinate of the client window.
96+
"""
97+
return self.x
98+
99+
def get_y(self) -> int:
100+
"""Gets the y coordinate of the client window.
101+
102+
Returns:
103+
-------
104+
int: The y coordinate of the client window.
105+
"""
106+
return self.y
107+
108+
def is_active(self) -> bool:
109+
"""Checks if the client window is active.
110+
111+
Returns:
112+
-------
113+
bool: True if the client window is active, False otherwise.
114+
"""
115+
return self.active
116+
117+
@classmethod
118+
def from_dict(cls, data: Dict) -> "ClientWindowInfo":
119+
"""Creates a ClientWindowInfo instance from a dictionary.
120+
121+
Parameters:
122+
-----------
123+
data: A dictionary containing the client window information.
124+
125+
Returns:
126+
-------
127+
ClientWindowInfo: A new instance of ClientWindowInfo.
128+
"""
129+
return cls(
130+
client_window=data.get("clientWindow"),
131+
state=data.get("state"),
132+
width=data.get("width"),
133+
height=data.get("height"),
134+
x=data.get("x"),
135+
y=data.get("y"),
136+
active=data.get("active"),
137+
)
138+
139+
140+
class Browser:
141+
"""
142+
BiDi implementation of the browser module.
143+
"""
144+
145+
def __init__(self, conn):
146+
self.conn = conn
147+
148+
def create_user_context(self) -> str:
149+
"""Creates a new user context.
150+
151+
Returns:
152+
-------
153+
str: The ID of the created user context.
154+
"""
155+
result = self.conn.execute(command_builder("browser.createUserContext", {}))
156+
return result["userContext"]
157+
158+
def get_user_contexts(self) -> List[str]:
159+
"""Gets all user contexts.
160+
161+
Returns:
162+
-------
163+
List[str]: A list of user context IDs.
164+
"""
165+
result = self.conn.execute(command_builder("browser.getUserContexts", {}))
166+
return [context_info["userContext"] for context_info in result["userContexts"]]
167+
168+
def remove_user_context(self, user_context_id: str) -> None:
169+
"""Removes a user context.
170+
171+
Parameters:
172+
-----------
173+
user_context_id: The ID of the user context to remove.
174+
175+
Raises:
176+
------
177+
Exception: If the user context ID is "default" or does not exist.
178+
"""
179+
if user_context_id == "default":
180+
raise Exception("Cannot remove the default user context")
181+
182+
params = {"userContext": user_context_id}
183+
self.conn.execute(command_builder("browser.removeUserContext", params))
184+
185+
def get_client_windows(self) -> List[ClientWindowInfo]:
186+
"""Gets all client windows.
187+
188+
Returns:
189+
-------
190+
List[ClientWindowInfo]: A list of client window information.
191+
"""
192+
result = self.conn.execute(command_builder("browser.getClientWindows", {}))
193+
return [ClientWindowInfo.from_dict(window) for window in result["clientWindows"]]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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
19+
20+
21+
def command_builder(method: str, params: Dict = None) -> Dict:
22+
"""Build a command iterator to send to the BiDi protocol.
23+
24+
Parameters:
25+
-----------
26+
method: The method to execute.
27+
params: The parameters to pass to the method. Default is None.
28+
29+
Returns:
30+
--------
31+
The response from the command execution.
32+
"""
33+
if params is None:
34+
params = {}
35+
36+
command = {"method": method, "params": params}
37+
cmd = yield command
38+
return cmd

py/selenium/webdriver/common/bidi/network.py

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717

18+
from selenium.webdriver.common.bidi.common import command_builder
19+
1820

1921
class NetworkEvent:
2022
"""Represents a network event."""
@@ -51,18 +53,6 @@ def __init__(self, conn):
5153
self.callbacks = {}
5254
self.subscriptions = {}
5355

54-
def command_builder(self, method, params):
55-
"""Build a command iterator to send to the network.
56-
57-
Parameters:
58-
----------
59-
method (str): The method to execute.
60-
params (dict): The parameters to pass to the method.
61-
"""
62-
command = {"method": method, "params": params}
63-
cmd = yield command
64-
return cmd
65-
6656
def _add_intercept(self, phases=[], contexts=None, url_patterns=None):
6757
"""Add an intercept to the network.
6858
@@ -88,7 +78,7 @@ def _add_intercept(self, phases=[], contexts=None, url_patterns=None):
8878
params["phases"] = phases
8979
else:
9080
params["phases"] = ["beforeRequestSent"]
91-
cmd = self.command_builder("network.addIntercept", params)
81+
cmd = command_builder("network.addIntercept", params)
9282

9383
result = self.conn.execute(cmd)
9484
self.intercepts.append(result["intercept"])
@@ -113,11 +103,11 @@ def _remove_intercept(self, intercept=None):
113103
if intercept is None:
114104
intercepts_to_remove = self.intercepts.copy() # create a copy before iterating
115105
for intercept_id in intercepts_to_remove: # remove all intercepts
116-
self.conn.execute(self.command_builder("network.removeIntercept", {"intercept": intercept_id}))
106+
self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept_id}))
117107
self.intercepts.remove(intercept_id)
118108
else:
119109
try:
120-
self.conn.execute(self.command_builder("network.removeIntercept", {"intercept": intercept}))
110+
self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept}))
121111
self.intercepts.remove(intercept)
122112
except Exception as e:
123113
raise Exception(f"Exception: {e}")
@@ -192,7 +182,7 @@ def add_request_handler(self, event, callback, url_patterns=None, contexts=None)
192182
else:
193183
params = {}
194184
params["events"] = [event_name]
195-
self.conn.execute(self.command_builder("session.subscribe", params))
185+
self.conn.execute(command_builder("session.subscribe", params))
196186
self.subscriptions[event_name] = [callback_id]
197187

198188
self.callbacks[callback_id] = result["intercept"]
@@ -220,7 +210,7 @@ def remove_request_handler(self, event, callback_id):
220210
if len(self.subscriptions[event_name]) == 0:
221211
params = {}
222212
params["events"] = [event_name]
223-
self.conn.execute(self.command_builder("session.unsubscribe", params))
213+
self.conn.execute(command_builder("session.unsubscribe", params))
224214
del self.subscriptions[event_name]
225215

226216
def clear_request_handlers(self):
@@ -234,7 +224,7 @@ def clear_request_handlers(self):
234224
del self.callbacks[callback_id]
235225
params = {}
236226
params["events"] = [event_name]
237-
self.conn.execute(self.command_builder("session.unsubscribe", params))
227+
self.conn.execute(command_builder("session.unsubscribe", params))
238228
self.subscriptions = {}
239229

240230
def add_auth_handler(self, username, password):
@@ -294,26 +284,14 @@ def __init__(
294284
self.timings = timings
295285
self.url = url
296286

297-
def command_builder(self, method, params):
298-
"""Build a command iterator to send to the network.
299-
300-
Parameters:
301-
----------
302-
method (str): The method to execute.
303-
params (dict): The parameters to pass to the method.
304-
"""
305-
command = {"method": method, "params": params}
306-
cmd = yield command
307-
return cmd
308-
309287
def fail_request(self):
310288
"""Fail this request."""
311289

312290
if not self.request_id:
313291
raise ValueError("Request not found.")
314292

315293
params = {"request": self.request_id}
316-
self.network.conn.execute(self.command_builder("network.failRequest", params))
294+
self.network.conn.execute(command_builder("network.failRequest", params))
317295

318296
def continue_request(self, body=None, method=None, headers=None, cookies=None, url=None):
319297
"""Continue after intercepting this request."""
@@ -333,7 +311,7 @@ def continue_request(self, body=None, method=None, headers=None, cookies=None, u
333311
if url is not None:
334312
params["url"] = url
335313

336-
self.network.conn.execute(self.command_builder("network.continueRequest", params))
314+
self.network.conn.execute(command_builder("network.continueRequest", params))
337315

338316
def _continue_with_auth(self, username=None, password=None):
339317
"""Continue with authentication.
@@ -358,4 +336,4 @@ def _continue_with_auth(self, username=None, password=None):
358336
params["action"] = "provideCredentials"
359337
params["credentials"] = {"type": "password", "username": username, "password": password}
360338

361-
self.network.conn.execute(self.command_builder("network.continueWithAuth", params))
339+
self.network.conn.execute(command_builder("network.continueWithAuth", params))

py/selenium/webdriver/remote/webdriver.py

Lines changed: 25 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.browser import Browser
4445
from selenium.webdriver.common.bidi.network import Network
4546
from selenium.webdriver.common.bidi.script import Script
4647
from selenium.webdriver.common.by import By
@@ -254,6 +255,7 @@ def __init__(
254255
self._websocket_connection = None
255256
self._script = None
256257
self._network = None
258+
self._browser = None
257259

258260
def __repr__(self):
259261
return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>'
@@ -1269,6 +1271,29 @@ def network(self):
12691271

12701272
return self._network
12711273

1274+
@property
1275+
def browser(self):
1276+
"""Returns a browser module object for BiDi browser commands.
1277+
1278+
Returns:
1279+
--------
1280+
Browser: an object containing access to BiDi browser commands.
1281+
1282+
Examples:
1283+
---------
1284+
>>> user_context = driver.browser.create_user_context()
1285+
>>> user_contexts = driver.browser.get_user_contexts()
1286+
>>> client_windows = driver.browser.get_client_windows()
1287+
>>> driver.browser.remove_user_context(user_context)
1288+
"""
1289+
if not self._websocket_connection:
1290+
self._start_bidi()
1291+
1292+
if self._browser is None:
1293+
self._browser = Browser(self._websocket_connection)
1294+
1295+
return self._browser
1296+
12721297
def _get_cdp_details(self):
12731298
import json
12741299

0 commit comments

Comments
 (0)