Repository: SeleniumHQ/selenium
Pull request #15616: [py][bidi]: Implement BiDi browser module 
Author: login: navin772
Labels: name: C-py
description: Python Bindings
name: B-devtools
description: Includes everything BiDi or Chrome DevTools related
name: Review effort 3/5
Created: 2025-04-11T13:50:25Z | Merged: 2025-04-11T19:38:03Z

### **User description**
<!-- Thanks for Contributing to Selenium! -->
<!-- Please read our contribution guidelines: https://github.com/SeleniumHQ/selenium/blob/trunk/CONTRIBUTING.md -->

### 🔗 Related Issues
<!-- Example: Fixes #1234 or Closes #5678 -->
<!-- If the reason for this PR is not obvious, consider creating an issue for it first -->

### 💥 What does this PR do?
<!-- Describe what this change includes and how it works -->
Implements the BiDi browser module for the python bindings - https://w3c.github.io/webdriver-bidi/#module-browser.

These are the commands that have been added:
1. `create_user_context`
2. `get_user_contexts`
3. `remove_user_context`
4. `get_client_windows`

The PR also adds a new `common.py` file in the `bidi/` directory that will contain the commonly used functions like `command_builder` to avoid repetition.

### 🔧 Implementation Notes
<!--- Why did you implement it this way? -->
<!--- What alternatives to this approach did you consider? -->

### 💡 Additional Considerations
<!--- Are there any decisions that need to be made before accepting this PR? -->
<!--- Is there any follow-on work that needs to be done? (e.g., docs, tests, etc.) -->

### 🔄 Types of changes
<!-- ✂️ Please delete anything that doesn't apply -->
- New feature (non-breaking change which adds functionality *and tests!*)


___

### **PR Type**
Enhancement, Tests


___

### **Description**
- Implemented BiDi browser module for Python bindings.

- Added commands for user context and client window management.

- Refactored common BiDi command builder to avoid duplication.

- Introduced comprehensive tests for BiDi browser functionality.


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>browser.py</strong><dd><code>Added BiDi browser module implementation</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

py/selenium/webdriver/common/bidi/browser.py

<li>Added BiDi browser module implementation.<br> <li> Introduced user context and client window management commands.<br> <li> Defined <code>ClientWindowInfo</code> and <code>ClientWindowState</code> classes.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-cd7018a350739e3db649c9e7f186036670f27a06f8df204de5909a03d7a04f5f">+190/-0</a>&nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>common.py</strong><dd><code>Added reusable BiDi command builder</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

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

<li>Added a reusable <code>command_builder</code> function for BiDi commands.<br> <li> Simplified command construction for BiDi modules.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-d38471cbc3bd4a86a19ba1a6c8ea1673298440fa4409ce3e24e2cd581fd3cc6a">+38/-0</a>&nbsp; &nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>network.py</strong><dd><code>Refactored BiDi network commands to use common builder</code>&nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

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

<li>Replaced inline command builders with the common <code>command_builder</code>.<br> <li> Simplified network-related BiDi command execution.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-d04fc5573acd6ca5bcc8167940f134f1a3ead8a5230c066c077a1319a1ffe1d4">+11/-33</a>&nbsp; </td>

</tr>

<tr>
  <td>
    <details>
      <summary><strong>webdriver.py</strong><dd><code>Integrated BiDi browser module into WebDriver</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

py/selenium/webdriver/remote/webdriver.py

<li>Integrated BiDi browser module into WebDriver.<br> <li> Added <code>browser</code> property for accessing BiDi browser commands.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-c87c150ed2a08f5293db58a06da8bf7f14c3a5582586c6270c1be661dfdd0985">+25/-0</a>&nbsp; &nbsp; </td>

</tr>
</table></td></tr><tr><td><strong>Miscellaneous</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>__init__.py</strong><dd><code>Added init file for BiDi tests</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

py/test/selenium/webdriver/common/bidi/__init__.py

- Added initialization file for BiDi tests.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-c215aff64fcc3e3bcba4b6133936c2739483821f7ef870ffe532348154072544">+17/-0</a>&nbsp; &nbsp; </td>

</tr>
</table></td></tr><tr><td><strong>Tests</strong></td><td><table>
<tr>
  <td>
    <details>
      <summary><strong>bidi_browser_tests.py</strong><dd><code>Added tests for BiDi browser module</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

py/test/selenium/webdriver/common/bidi_browser_tests.py

<li>Added tests for BiDi browser module functionality.<br> <li> Covered user context and client window management.<br> <li> Validated <code>ClientWindowState</code> constants.


</details>


  </td>
  <td><a href="https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-e0a8b792e4fc6fa62c5fae3b9c75b56d61e12e4d0929aa991ddb799276b53c3c">+99/-0</a>&nbsp; &nbsp; </td>

</tr>
</table></td></tr></tr></tbody></table>

___

> <details> <summary>  Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about Qodo Merge usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>

Linked issue #1234: 2.48 doesn't trigger javascript in link's href on click()
Please see https://drive.google.com/file/d/0Bzx3gCDqfzVdQUFoWHdzdWlFYVU/view?usp=sharing and https://drive.google.com/file/d/0Bzx3gCDqfzVdUmhhckFkWVFlbTQ/view?usp=sharing

The test case there alerts with 2.47.1 but not with 2.48.0 or 2.48.2. This is with Firefox 42.0 32bit on 64bit machine.

Linked issue comments: user: login: barancev
created_at: 2016-01-25T21:08:17Z
body: Can't reproduce the issue with Selenium 2.49.1 and Firefox 43.0.4, please retest with the latest Selenium version.

author_association: MEMBER
user: login: merike
created_at: 2016-02-01T11:52:40Z
body: Strange, for me 43.0.4 and 2.50.1 still don't trigger the alert. Wait.. except when Firefox is in the background. Why would it work differently when Firefox is the foreground application? Unless it's a reverse bug of #825?

author_association: NONE
user: login: barancev
created_at: 2016-03-02T06:12:24Z
body: Closing the issue as there is no reproduction case provided.

author_association: MEMBER

Diff:
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"


Reviews:
user: login: shbenzer
body: This looks great! 

Could we add the browser.SetClientWindowState browser command too?
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T17:14:13Z
user: login: navin772
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-11T17:20:20Z
user: login: shbenzer
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:32:14Z
user: login: shbenzer
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T18:33:07Z

Review comments:
diff_hunk: @@ -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):
path: py/test/selenium/webdriver/common/bidi_browser_tests.py
user: login: shbenzer
body: Could we add a test to ensure that setting window size with these works?
created_at: 2025-04-11T17:13:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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):
path: py/test/selenium/webdriver/common/bidi_browser_tests.py
user: login: navin772
body: Hi @shbenzer, I don't see the `browser.SetClientWindowState` tests in [WPT dashboard](https://wpt.fyi/results/webdriver/tests/bidi/browser?label=master&label=experimental&aligned&q=bidi) even for nightly versions, so I assume that they are not yet implemented and will fail if I implement them. 

I can add them later once they are generally available. Same is the case with `browser.close`
created_at: 2025-04-11T17:20:20Z
author_association: MEMBER
diff_hunk: @@ -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):
path: py/test/selenium/webdriver/common/bidi_browser_tests.py
user: login: shbenzer
body: Sounds good to me
created_at: 2025-04-11T18:32:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: qodo-code-review[bot]
created_at: 2025-04-11T13:51:17Z
body: ## PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

<table>
<tr><td>

**🎫 Ticket compliance analysis ❌**



**[1234](https://github.com/SeleniumHQ/selenium/issues/1234) - Not compliant**

Non-compliant requirements:

• Fix issue where JavaScript in link's href is not triggered on click() in Firefox 42.0 with Selenium 2.48.0/2.48.2
• Ensure click() properly executes JavaScript in href attributes
• Maintain backward compatibility with previous working behavior in Selenium 2.47.1





**[5678](https://github.com/SeleniumHQ/selenium/issues/5678) - Not compliant**

Non-compliant requirements:

• Fix "Error: ConnectFailure (Connection refused)" when instantiating ChromeDriver multiple times
• Address issue specific to Ubuntu 16.04.4 with Chrome 65.0.3325.181 and ChromeDriver 2.35
• Ensure subsequent ChromeDriver instances work properly after first instance



</td></tr>
<tr><td>⏱️&nbsp;<strong>Estimated effort to review</strong>: 3 🔵🔵🔵⚪⚪</td></tr>
<tr><td>🧪&nbsp;<strong>PR contains tests</strong></td></tr>
<tr><td>🔒&nbsp;<strong>No security concerns identified</strong></td></tr>
<tr><td>⚡&nbsp;<strong>Recommended focus areas for review</strong><br><br>

<details><summary><a href='https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-cd7018a350739e3db649c9e7f186036670f27a06f8df204de5909a03d7a04f5fR148-R190'><strong>Error Handling</strong></a>

The create_user_context and get_client_windows methods don't include error handling for potential BiDi protocol errors or connection issues. Consider adding try/except blocks to handle potential exceptions.
</summary>

```python
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.
    """
    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"]]
```

</details>

<details><summary><a href='https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-d38471cbc3bd4a86a19ba1a6c8ea1673298440fa4409ce3e24e2cd581fd3cc6aR21-R38'><strong>Command Builder Return</strong></a>

The command_builder function uses yield to create a generator but the docstring indicates it returns a response. This may cause confusion as the actual response comes from the yield statement, not the function return.
</summary>

```python
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
```

</details>

<details><summary><a href='https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-e0a8b792e4fc6fa62c5fae3b9c75b56d61e12e4d0929aa991ddb799276b53c3cR75-R91'><strong>Test Assertions</strong></a>

The test_get_client_windows has assertions that may fail on some browser configurations where window coordinates could be zero or negative. Consider making these assertions more flexible.
</summary>

```python
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
```

</details>

</td></tr>
</table>

author_association: CONTRIBUTOR
user: login: qodo-code-review[bot]
created_at: 2025-04-11T13:51:50Z
body: ## PR Code Suggestions ✨

<!-- 75d465f -->

Explore these optional code suggestions:

<table><thead><tr><td><strong>Category</strong></td><td align=left><strong>Suggestion&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </strong></td><td align=center><strong>Impact</strong></td></tr><tbody><tr><td rowspan=2>Possible issue</td>
<td>



<details><summary>✅ 
Prevent removing default context
</summary>

___

<details><summary><b>Suggestion Impact:</b></summary>The suggestion was directly implemented in the commit. The code now checks if the user_context_id is 'default' and raises an exception if it is, preventing the removal of the default user context.

code diff:

```diff
+        if user_context_id == "default":
+            raise Exception("Cannot remove the default user context")
+
```

</details>


___


**Add validation to prevent removing the "default" user context. The docstring <br>mentions that an exception will be raised if the user context ID is "default", <br>but there's no actual validation in the code to prevent this operation.**

[py/selenium/webdriver/common/bidi/browser.py [168-180]](https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-cd7018a350739e3db649c9e7f186036670f27a06f8df204de5909a03d7a04f5fR168-R180)

```diff
 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))
```

`[Suggestion has been applied]`

<details><summary>Suggestion importance[1-10]: 9</summary>

__

Why: The suggestion adds critical validation to prevent removing the "default" user context, which aligns with the documented behavior in the docstring. Without this check, users could attempt to remove the default context, potentially causing system instability or unexpected behavior.


</details></details></td><td align=center>High

</td></tr><tr><td>



<details><summary>Validate required fields</summary>

___


**Add validation for required fields in the <code>from_dict</code> method. Currently, if any of <br>the expected keys are missing in the input dictionary, the method will silently <br>pass <code>None</code> values to the constructor, which could lead to unexpected behavior or <br>errors later.**

[py/selenium/webdriver/common/bidi/browser.py [117-137]](https://github.com/SeleniumHQ/selenium/pull/15616/files#diff-cd7018a350739e3db649c9e7f186036670f27a06f8df204de5909a03d7a04f5fR117-R137)

```diff
 @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.
+        
+    Raises:
+    ------
+        KeyError: If any required field is missing from the data dictionary.
     """
+    required_fields = ["clientWindow", "state", "width", "height", "x", "y", "active"]
+    for field in required_fields:
+        if field not in data:
+            raise KeyError(f"Required field '{field}' missing from client window data")
+            
     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"),
+        client_window=data["clientWindow"],
+        state=data["state"],
+        width=data["width"],
+        height=data["height"],
+        x=data["x"],
+        y=data["y"],
+        active=data["active"],
     )
```

- [ ] **Apply this suggestion** <!-- /improve --apply_suggestion=1 -->

<details><summary>Suggestion importance[1-10]: 8</summary>

__

Why: This suggestion adds important validation for required fields in the from_dict method, preventing silent failures when dictionary keys are missing. This would catch potential bugs early and provide clear error messages instead of allowing None values to propagate through the system.


</details></details></td><td align=center>Medium

</td></tr>
<tr><td align="center" colspan="2">

- [ ] Update <!-- /improve --more_suggestions=true -->

</td><td></td></tr></tbody></table>
author_association: CONTRIBUTOR
user: login: navin772
created_at: 2025-04-11T13:56:25Z
body: @shbenzer thank you for implementing the Network module in python, I have implemented the Browser module taking inspiration from it! Feel free to review this.
author_association: MEMBER
user: login: shbenzer
created_at: 2025-04-11T19:48:56Z
body: Great work @navin772!
author_association: CONTRIBUTOR