diff --git a/py/selenium/webdriver/common/bidi/browser.py b/py/selenium/webdriver/common/bidi/browser.py new file mode 100644 index 0000000000000..a32c29847372d --- /dev/null +++ b/py/selenium/webdriver/common/bidi/browser.py @@ -0,0 +1,193 @@ +# 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 +from typing import List + +from selenium.webdriver.common.bidi.common import command_builder + + +class ClientWindowState: + """Represents a window state.""" + + FULLSCREEN = "fullscreen" + MAXIMIZED = "maximized" + MINIMIZED = "minimized" + NORMAL = "normal" + + +class ClientWindowInfo: + """Represents a client window information.""" + + def __init__( + self, + client_window: str, + state: str, + width: int, + height: int, + x: int, + y: int, + active: bool, + ): + self.client_window = client_window + self.state = state + self.width = width + self.height = height + self.x = x + self.y = y + self.active = active + + def get_state(self) -> str: + """Gets the state of the client window. + + Returns: + ------- + str: The state of the client window (one of the ClientWindowState constants). + """ + return self.state + + def get_client_window(self) -> str: + """Gets the client window identifier. + + Returns: + ------- + str: The client window identifier. + """ + return self.client_window + + def get_width(self) -> int: + """Gets the width of the client window. + + Returns: + ------- + int: The width of the client window. + """ + return self.width + + def get_height(self) -> int: + """Gets the height of the client window. + + Returns: + ------- + int: The height of the client window. + """ + return self.height + + def get_x(self) -> int: + """Gets the x coordinate of the client window. + + Returns: + ------- + int: The x coordinate of the client window. + """ + return self.x + + def get_y(self) -> int: + """Gets the y coordinate of the client window. + + Returns: + ------- + int: The y coordinate of the client window. + """ + return self.y + + def is_active(self) -> bool: + """Checks if the client window is active. + + Returns: + ------- + bool: True if the client window is active, False otherwise. + """ + return self.active + + @classmethod + def from_dict(cls, data: Dict) -> "ClientWindowInfo": + """Creates a ClientWindowInfo instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the client window information. + + Returns: + ------- + ClientWindowInfo: A new instance of ClientWindowInfo. + """ + return cls( + client_window=data.get("clientWindow"), + state=data.get("state"), + width=data.get("width"), + height=data.get("height"), + x=data.get("x"), + y=data.get("y"), + active=data.get("active"), + ) + + +class Browser: + """ + BiDi implementation of the browser module. + """ + + def __init__(self, conn): + self.conn = conn + + def create_user_context(self) -> str: + """Creates a new user context. + + Returns: + ------- + str: The ID of the created user context. + """ + result = self.conn.execute(command_builder("browser.createUserContext", {})) + return result["userContext"] + + def get_user_contexts(self) -> List[str]: + """Gets all user contexts. + + Returns: + ------- + List[str]: A list of user context IDs. + """ + result = self.conn.execute(command_builder("browser.getUserContexts", {})) + return [context_info["userContext"] for context_info in result["userContexts"]] + + def remove_user_context(self, user_context_id: str) -> None: + """Removes a user context. + + Parameters: + ----------- + user_context_id: The ID of the user context to remove. + + Raises: + ------ + Exception: If the user context ID is "default" or does not exist. + """ + if user_context_id == "default": + raise Exception("Cannot remove the default user context") + + params = {"userContext": user_context_id} + self.conn.execute(command_builder("browser.removeUserContext", params)) + + def get_client_windows(self) -> List[ClientWindowInfo]: + """Gets all client windows. + + Returns: + ------- + List[ClientWindowInfo]: A list of client window information. + """ + result = self.conn.execute(command_builder("browser.getClientWindows", {})) + return [ClientWindowInfo.from_dict(window) for window in result["clientWindows"]] diff --git a/py/selenium/webdriver/common/bidi/common.py b/py/selenium/webdriver/common/bidi/common.py new file mode 100644 index 0000000000000..38a4d7e31d3d5 --- /dev/null +++ b/py/selenium/webdriver/common/bidi/common.py @@ -0,0 +1,38 @@ +# 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 + + +def command_builder(method: str, params: Dict = None) -> Dict: + """Build a command iterator to send to the BiDi protocol. + + Parameters: + ----------- + method: The method to execute. + params: The parameters to pass to the method. Default is None. + + Returns: + -------- + The response from the command execution. + """ + if params is None: + params = {} + + command = {"method": method, "params": params} + cmd = yield command + return cmd diff --git a/py/selenium/webdriver/common/bidi/network.py b/py/selenium/webdriver/common/bidi/network.py index 36a0f9be32677..cecdbfb80d225 100644 --- a/py/selenium/webdriver/common/bidi/network.py +++ b/py/selenium/webdriver/common/bidi/network.py @@ -15,6 +15,8 @@ # specific language governing permissions and limitations # under the License. +from selenium.webdriver.common.bidi.common import command_builder + class NetworkEvent: """Represents a network event.""" @@ -51,18 +53,6 @@ def __init__(self, conn): self.callbacks = {} self.subscriptions = {} - def command_builder(self, method, params): - """Build a command iterator to send to the network. - - Parameters: - ---------- - method (str): The method to execute. - params (dict): The parameters to pass to the method. - """ - command = {"method": method, "params": params} - cmd = yield command - return cmd - def _add_intercept(self, phases=[], contexts=None, url_patterns=None): """Add an intercept to the network. @@ -88,7 +78,7 @@ def _add_intercept(self, phases=[], contexts=None, url_patterns=None): params["phases"] = phases else: params["phases"] = ["beforeRequestSent"] - cmd = self.command_builder("network.addIntercept", params) + cmd = command_builder("network.addIntercept", params) result = self.conn.execute(cmd) self.intercepts.append(result["intercept"]) @@ -113,11 +103,11 @@ def _remove_intercept(self, intercept=None): if intercept is None: intercepts_to_remove = self.intercepts.copy() # create a copy before iterating for intercept_id in intercepts_to_remove: # remove all intercepts - self.conn.execute(self.command_builder("network.removeIntercept", {"intercept": intercept_id})) + self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept_id})) self.intercepts.remove(intercept_id) else: try: - self.conn.execute(self.command_builder("network.removeIntercept", {"intercept": intercept})) + self.conn.execute(command_builder("network.removeIntercept", {"intercept": intercept})) self.intercepts.remove(intercept) except Exception as e: raise Exception(f"Exception: {e}") @@ -192,7 +182,7 @@ def add_request_handler(self, event, callback, url_patterns=None, contexts=None) else: params = {} params["events"] = [event_name] - self.conn.execute(self.command_builder("session.subscribe", params)) + self.conn.execute(command_builder("session.subscribe", params)) self.subscriptions[event_name] = [callback_id] self.callbacks[callback_id] = result["intercept"] @@ -220,7 +210,7 @@ def remove_request_handler(self, event, callback_id): if len(self.subscriptions[event_name]) == 0: params = {} params["events"] = [event_name] - self.conn.execute(self.command_builder("session.unsubscribe", params)) + self.conn.execute(command_builder("session.unsubscribe", params)) del self.subscriptions[event_name] def clear_request_handlers(self): @@ -234,7 +224,7 @@ def clear_request_handlers(self): del self.callbacks[callback_id] params = {} params["events"] = [event_name] - self.conn.execute(self.command_builder("session.unsubscribe", params)) + self.conn.execute(command_builder("session.unsubscribe", params)) self.subscriptions = {} def add_auth_handler(self, username, password): @@ -294,18 +284,6 @@ def __init__( self.timings = timings self.url = url - def command_builder(self, method, params): - """Build a command iterator to send to the network. - - Parameters: - ---------- - method (str): The method to execute. - params (dict): The parameters to pass to the method. - """ - command = {"method": method, "params": params} - cmd = yield command - return cmd - def fail_request(self): """Fail this request.""" @@ -313,7 +291,7 @@ def fail_request(self): raise ValueError("Request not found.") params = {"request": self.request_id} - self.network.conn.execute(self.command_builder("network.failRequest", params)) + self.network.conn.execute(command_builder("network.failRequest", params)) def continue_request(self, body=None, method=None, headers=None, cookies=None, url=None): """Continue after intercepting this request.""" @@ -333,7 +311,7 @@ def continue_request(self, body=None, method=None, headers=None, cookies=None, u if url is not None: params["url"] = url - self.network.conn.execute(self.command_builder("network.continueRequest", params)) + self.network.conn.execute(command_builder("network.continueRequest", params)) def _continue_with_auth(self, username=None, password=None): """Continue with authentication. @@ -358,4 +336,4 @@ def _continue_with_auth(self, username=None, password=None): params["action"] = "provideCredentials" params["credentials"] = {"type": "password", "username": username, "password": password} - self.network.conn.execute(self.command_builder("network.continueWithAuth", params)) + self.network.conn.execute(command_builder("network.continueWithAuth", params)) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index a97952d84383f..2c192673a7089 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -41,6 +41,7 @@ from selenium.common.exceptions import NoSuchCookieException from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common.bidi.browser import Browser from selenium.webdriver.common.bidi.network import Network from selenium.webdriver.common.bidi.script import Script from selenium.webdriver.common.by import By @@ -254,6 +255,7 @@ def __init__( self._websocket_connection = None self._script = None self._network = None + self._browser = None def __repr__(self): return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>' @@ -1269,6 +1271,29 @@ def network(self): return self._network + @property + def browser(self): + """Returns a browser module object for BiDi browser commands. + + Returns: + -------- + Browser: an object containing access to BiDi browser commands. + + Examples: + --------- + >>> user_context = driver.browser.create_user_context() + >>> user_contexts = driver.browser.get_user_contexts() + >>> client_windows = driver.browser.get_client_windows() + >>> driver.browser.remove_user_context(user_context) + """ + if not self._websocket_connection: + self._start_bidi() + + if self._browser is None: + self._browser = Browser(self._websocket_connection) + + return self._browser + def _get_cdp_details(self): import json diff --git a/py/test/selenium/webdriver/common/bidi_browser_tests.py b/py/test/selenium/webdriver/common/bidi_browser_tests.py new file mode 100644 index 0000000000000..72d288333f0b4 --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi_browser_tests.py @@ -0,0 +1,105 @@ +# 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 pytest + +from selenium.webdriver.common.bidi.browser import ClientWindowInfo +from selenium.webdriver.common.bidi.browser import ClientWindowState + + +@pytest.mark.xfail_safari +def test_browser_initialized(driver): + """Test that the browser module is initialized properly.""" + assert driver.browser is not None + + +@pytest.mark.xfail_safari +def test_create_user_context(driver): + """Test creating a user context.""" + user_context = driver.browser.create_user_context() + assert user_context is not None + + # Clean up + driver.browser.remove_user_context(user_context) + + +@pytest.mark.xfail_safari +def test_get_user_contexts(driver): + """Test getting user contexts.""" + user_context1 = driver.browser.create_user_context() + user_context2 = driver.browser.create_user_context() + + user_contexts = driver.browser.get_user_contexts() + # it should be 3 since there is a default user context present, therefore >= 2 + assert len(user_contexts) >= 2 + + # Clean up + driver.browser.remove_user_context(user_context1) + driver.browser.remove_user_context(user_context2) + + +@pytest.mark.xfail_safari +def test_remove_user_context(driver): + """Test removing a user context.""" + user_context1 = driver.browser.create_user_context() + user_context2 = driver.browser.create_user_context() + + user_contexts = driver.browser.get_user_contexts() + assert len(user_contexts) >= 2 + + driver.browser.remove_user_context(user_context2) + + updated_user_contexts = driver.browser.get_user_contexts() + assert user_context1 in updated_user_contexts + assert user_context2 not in updated_user_contexts + + # Clean up + driver.browser.remove_user_context(user_context1) + + +@pytest.mark.xfail_safari +def test_get_client_windows(driver): + """Test getting client windows.""" + client_windows = driver.browser.get_client_windows() + + assert client_windows is not None + assert len(client_windows) > 0 + + window_info = client_windows[0] + assert isinstance(window_info, ClientWindowInfo) + assert window_info.get_client_window() is not None + assert window_info.get_state() is not None + assert isinstance(window_info.get_state(), str) + assert window_info.get_width() > 0 + assert window_info.get_height() > 0 + assert isinstance(window_info.is_active(), bool) + assert window_info.get_x() >= 0 + assert window_info.get_y() >= 0 + + +@pytest.mark.xfail_safari +def test_raises_exception_when_removing_default_user_context(driver): + with pytest.raises(Exception): + driver.browser.remove_user_context("default") + + +@pytest.mark.xfail_safari +def test_client_window_state_constants(driver): + assert ClientWindowState.FULLSCREEN == "fullscreen" + assert ClientWindowState.MAXIMIZED == "maximized" + assert ClientWindowState.MINIMIZED == "minimized" + assert ClientWindowState.NORMAL == "normal"