diff --git a/py/selenium/webdriver/common/bidi/emulation.py b/py/selenium/webdriver/common/bidi/emulation.py new file mode 100644 index 0000000000000..45c91f5ec0b2b --- /dev/null +++ b/py/selenium/webdriver/common/bidi/emulation.py @@ -0,0 +1,162 @@ +# 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 Any, Dict, List, Optional, Union + +from selenium.webdriver.common.bidi.common import command_builder + + +class GeolocationCoordinates: + """Represents geolocation coordinates.""" + + def __init__( + self, + latitude: float, + longitude: float, + accuracy: float = 1.0, + altitude: Optional[float] = None, + altitude_accuracy: Optional[float] = None, + heading: Optional[float] = None, + speed: Optional[float] = None, + ): + """Initialize GeolocationCoordinates. + + Parameters: + ----------- + latitude: Latitude coordinate (-90.0 to 90.0). + longitude: Longitude coordinate (-180.0 to 180.0). + accuracy: Accuracy in meters (>= 0.0), defaults to 1.0. + altitude: Altitude in meters or None, defaults to None. + altitude_accuracy: Altitude accuracy in meters (>= 0.0) or None, defaults to None. + heading: Heading in degrees (0.0 to 360.0) or None, defaults to None. + speed: Speed in meters per second (>= 0.0) or None, defaults to None. + + Raises: + ------ + ValueError: If coordinates are out of valid range or if altitude_accuracy is provided without altitude. + """ + if not (-90.0 <= latitude <= 90.0): + raise ValueError("Latitude must be between -90.0 and 90.0") + if not (-180.0 <= longitude <= 180.0): + raise ValueError("Longitude must be between -180.0 and 180.0") + if accuracy < 0.0: + raise ValueError("Accuracy must be >= 0.0") + if altitude_accuracy is not None and altitude is None: + raise ValueError("altitude_accuracy cannot be set without altitude") + if altitude_accuracy is not None and altitude_accuracy < 0.0: + raise ValueError("Altitude accuracy must be >= 0.0") + if heading is not None and not (0.0 <= heading < 360.0): + raise ValueError("Heading must be between 0.0 and 360.0") + if speed is not None and speed < 0.0: + raise ValueError("Speed must be >= 0.0") + + self.latitude = latitude + self.longitude = longitude + self.accuracy = accuracy + self.altitude = altitude + self.altitude_accuracy = altitude_accuracy + self.heading = heading + self.speed = speed + + def to_dict(self) -> Dict[str, Union[float, None]]: + result: Dict[str, Union[float, None]] = { + "latitude": self.latitude, + "longitude": self.longitude, + "accuracy": self.accuracy, + } + + if self.altitude is not None: + result["altitude"] = self.altitude + + if self.altitude_accuracy is not None: + result["altitudeAccuracy"] = self.altitude_accuracy + + if self.heading is not None: + result["heading"] = self.heading + + if self.speed is not None: + result["speed"] = self.speed + + return result + + +class GeolocationPositionError: + """Represents a geolocation position error.""" + + TYPE_POSITION_UNAVAILABLE = "positionUnavailable" + + def __init__(self, type: str = TYPE_POSITION_UNAVAILABLE): + if type != self.TYPE_POSITION_UNAVAILABLE: + raise ValueError(f'type must be "{self.TYPE_POSITION_UNAVAILABLE}"') + self.type = type + + def to_dict(self) -> Dict[str, str]: + return {"type": self.type} + + +class Emulation: + """ + BiDi implementation of the emulation module. + """ + + def __init__(self, conn): + self.conn = conn + + def set_geolocation_override( + self, + coordinates: Optional[GeolocationCoordinates] = None, + error: Optional[GeolocationPositionError] = None, + contexts: Optional[List[str]] = None, + user_contexts: Optional[List[str]] = None, + ) -> None: + """Set geolocation override for the given contexts or user contexts. + + Parameters: + ----------- + coordinates: Geolocation coordinates to emulate, or None. + error: Geolocation error to emulate, or None. + contexts: List of browsing context IDs to apply the override to. + user_contexts: List of user context IDs to apply the override to. + + Raises: + ------ + ValueError: If both coordinates and error are provided, or if both contexts + and user_contexts are provided, or if neither contexts nor + user_contexts are provided. + """ + if coordinates is not None and error is not None: + raise ValueError("Cannot specify both coordinates and error") + + if contexts is not None and user_contexts is not None: + raise ValueError("Cannot specify both contexts and userContexts") + + if contexts is None and user_contexts is None: + raise ValueError("Must specify either contexts or userContexts") + + params: Dict[str, Any] = {} + + if coordinates is not None: + params["coordinates"] = coordinates.to_dict() + elif error is not None: + params["error"] = error.to_dict() + + if contexts is not None: + params["contexts"] = contexts + elif user_contexts is not None: + params["userContexts"] = user_contexts + + self.conn.execute(command_builder("emulation.setGeolocationOverride", params)) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index 62ce619105186..7ccc53c92afc9 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -40,6 +40,7 @@ ) from selenium.webdriver.common.bidi.browser import Browser from selenium.webdriver.common.bidi.browsing_context import BrowsingContext +from selenium.webdriver.common.bidi.emulation import Emulation from selenium.webdriver.common.bidi.network import Network from selenium.webdriver.common.bidi.permissions import Permissions from selenium.webdriver.common.bidi.script import Script @@ -270,6 +271,7 @@ def __init__( self._storage = None self._webextension = None self._permissions = None + self._emulation = None self._devtools = None def __repr__(self): @@ -1390,6 +1392,28 @@ def webextension(self): return self._webextension + @property + def emulation(self): + """Returns an emulation module object for BiDi emulation commands. + + Returns: + -------- + Emulation: an object containing access to BiDi emulation commands. + + Examples: + --------- + >>> from selenium.webdriver.common.bidi.emulation import GeolocationCoordinates + >>> coordinates = GeolocationCoordinates(37.7749, -122.4194) + >>> driver.emulation.set_geolocation_override(coordinates=coordinates, contexts=[context_id]) + """ + if not self._websocket_connection: + self._start_bidi() + + if self._emulation is None: + self._emulation = Emulation(self._websocket_connection) + + return self._emulation + def _get_cdp_details(self): import json diff --git a/py/test/selenium/webdriver/common/bidi_emulation_tests.py b/py/test/selenium/webdriver/common/bidi_emulation_tests.py new file mode 100644 index 0000000000000..dc7a704131412 --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi_emulation_tests.py @@ -0,0 +1,214 @@ +# 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 selenium.webdriver.common.bidi.emulation import Emulation, GeolocationCoordinates +from selenium.webdriver.common.bidi.permissions import PermissionState +from selenium.webdriver.common.window import WindowTypes + + +def get_browser_geolocation(driver, user_context=None): + origin = driver.execute_script("return window.location.origin;") + driver.permissions.set_permission("geolocation", PermissionState.GRANTED, origin, user_context=user_context) + + return driver.execute_async_script(""" + const callback = arguments[arguments.length - 1]; + navigator.geolocation.getCurrentPosition( + position => { + const coords = position.coords; + callback({ + latitude: coords.latitude, + longitude: coords.longitude, + accuracy: coords.accuracy, + altitude: coords.altitude, + altitudeAccuracy: coords.altitudeAccuracy, + heading: coords.heading, + speed: coords.speed, + timestamp: position.timestamp + }); + }, + error => { + callback({ error: error.message }); + } + ); + """) + + +def test_emulation_initialized(driver): + """Test that the emulation module is initialized properly.""" + assert driver.emulation is not None + assert isinstance(driver.emulation, Emulation) + + +def test_set_geolocation_override_with_coordinates_in_context(driver, pages): + """Test setting geolocation override with coordinates.""" + context_id = driver.current_window_handle + pages.load("blank.html") + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + driver.emulation.set_geolocation_override(coordinates=coords, contexts=[context_id]) + + result = get_browser_geolocation(driver) + + assert "error" not in result, f"Geolocation error: {result.get('error')}" + assert abs(result["latitude"] - coords.latitude) < 0.0001, f"Latitude mismatch: {result['latitude']}" + assert abs(result["longitude"] - coords.longitude) < 0.0001, f"Longitude mismatch: {result['longitude']}" + assert abs(result["accuracy"] - coords.accuracy) < 1.0, f"Accuracy mismatch: {result['accuracy']}" + + +def test_set_geolocation_override_with_coordinates_in_user_context(driver, pages): + """Test setting geolocation override with coordinates in a user context.""" + # Create a user context + user_context = driver.browser.create_user_context() + + context_id = driver.browsing_context.create(type=WindowTypes.TAB, user_context=user_context) + + driver.switch_to.window(context_id) + pages.load("blank.html") + + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + driver.emulation.set_geolocation_override(coordinates=coords, user_contexts=[user_context]) + + result = get_browser_geolocation(driver, user_context=user_context) + + assert "error" not in result, f"Geolocation error: {result.get('error')}" + assert abs(result["latitude"] - coords.latitude) < 0.0001, f"Latitude mismatch: {result['latitude']}" + assert abs(result["longitude"] - coords.longitude) < 0.0001, f"Longitude mismatch: {result['longitude']}" + assert abs(result["accuracy"] - coords.accuracy) < 1.0, f"Accuracy mismatch: {result['accuracy']}" + + driver.browsing_context.close(context_id) + driver.browser.remove_user_context(user_context) + + +def test_set_geolocation_override_all_coords(driver, pages): + """Test setting geolocation override with coordinates.""" + context_id = driver.current_window_handle + pages.load("blank.html") + coords = GeolocationCoordinates( + 45.5, -122.4194, accuracy=10.0, altitude=100.2, altitude_accuracy=5.0, heading=183.2, speed=10.0 + ) + + driver.emulation.set_geolocation_override(coordinates=coords, contexts=[context_id]) + + result = get_browser_geolocation(driver) + + assert "error" not in result, f"Geolocation error: {result.get('error')}" + assert abs(result["latitude"] - coords.latitude) < 0.0001, f"Latitude mismatch: {result['latitude']}" + assert abs(result["longitude"] - coords.longitude) < 0.0001, f"Longitude mismatch: {result['longitude']}" + assert abs(result["accuracy"] - coords.accuracy) < 1.0, f"Accuracy mismatch: {result['accuracy']}" + assert abs(result["altitude"] - coords.altitude) < 0.0001, f"Altitude mismatch: {result['altitude']}" + assert abs(result["altitudeAccuracy"] - coords.altitude_accuracy) < 0.1, ( + f"Altitude accuracy mismatch: {result['altitudeAccuracy']}" + ) + assert abs(result["heading"] - coords.heading) < 0.1, f"Heading mismatch: {result['heading']}" + assert abs(result["speed"] - coords.speed) < 0.1, f"Speed mismatch: {result['speed']}" + + driver.browsing_context.close(context_id) + + +def test_set_geolocation_override_with_multiple_contexts(driver, pages): + """Test setting geolocation override with multiple browsing contexts.""" + # Create two browsing contexts + context1_id = driver.browsing_context.create(type=WindowTypes.TAB) + context2_id = driver.browsing_context.create(type=WindowTypes.TAB) + + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + driver.emulation.set_geolocation_override(coordinates=coords, contexts=[context1_id, context2_id]) + + # Test first context + driver.switch_to.window(context1_id) + pages.load("blank.html") + result1 = get_browser_geolocation(driver) + + assert "error" not in result1, f"Geolocation error in context1: {result1.get('error')}" + assert abs(result1["latitude"] - coords.latitude) < 0.0001, f"Context1 latitude mismatch: {result1['latitude']}" + assert abs(result1["longitude"] - coords.longitude) < 0.0001, f"Context1 longitude mismatch: {result1['longitude']}" + assert abs(result1["accuracy"] - coords.accuracy) < 1.0, f"Context1 accuracy mismatch: {result1['accuracy']}" + + # Test second context + driver.switch_to.window(context2_id) + pages.load("blank.html") + result2 = get_browser_geolocation(driver) + + assert "error" not in result2, f"Geolocation error in context2: {result2.get('error')}" + assert abs(result2["latitude"] - coords.latitude) < 0.0001, f"Context2 latitude mismatch: {result2['latitude']}" + assert abs(result2["longitude"] - coords.longitude) < 0.0001, f"Context2 longitude mismatch: {result2['longitude']}" + assert abs(result2["accuracy"] - coords.accuracy) < 1.0, f"Context2 accuracy mismatch: {result2['accuracy']}" + + driver.browsing_context.close(context1_id) + driver.browsing_context.close(context2_id) + + +def test_set_geolocation_override_with_multiple_user_contexts(driver, pages): + """Test setting geolocation override with multiple user contexts.""" + # Create two user contexts + user_context1 = driver.browser.create_user_context() + user_context2 = driver.browser.create_user_context() + + context1_id = driver.browsing_context.create(type=WindowTypes.TAB, user_context=user_context1) + context2_id = driver.browsing_context.create(type=WindowTypes.TAB, user_context=user_context2) + + coords = GeolocationCoordinates(45.5, -122.4194, accuracy=10.0) + + driver.emulation.set_geolocation_override(coordinates=coords, user_contexts=[user_context1, user_context2]) + + # Test first user context + driver.switch_to.window(context1_id) + pages.load("blank.html") + result1 = get_browser_geolocation(driver, user_context=user_context1) + + assert "error" not in result1, f"Geolocation error in user_context1: {result1.get('error')}" + assert abs(result1["latitude"] - coords.latitude) < 0.0001, ( + f"User context1 latitude mismatch: {result1['latitude']}" + ) + assert abs(result1["longitude"] - coords.longitude) < 0.0001, ( + f"User context1 longitude mismatch: {result1['longitude']}" + ) + assert abs(result1["accuracy"] - coords.accuracy) < 1.0, f"User context1 accuracy mismatch: {result1['accuracy']}" + + # Test second user context + driver.switch_to.window(context2_id) + pages.load("blank.html") + result2 = get_browser_geolocation(driver, user_context=user_context2) + + assert "error" not in result2, f"Geolocation error in user_context2: {result2.get('error')}" + assert abs(result2["latitude"] - coords.latitude) < 0.0001, ( + f"User context2 latitude mismatch: {result2['latitude']}" + ) + assert abs(result2["longitude"] - coords.longitude) < 0.0001, ( + f"User context2 longitude mismatch: {result2['longitude']}" + ) + assert abs(result2["accuracy"] - coords.accuracy) < 1.0, f"User context2 accuracy mismatch: {result2['accuracy']}" + + driver.browsing_context.close(context1_id) + driver.browsing_context.close(context2_id) + driver.browser.remove_user_context(user_context1) + driver.browser.remove_user_context(user_context2) + + +# the error param returns "invalid argument: Invalid input in "coordinates" error, Chrome 138 fixes this +# @pytest.mark.xfail_firefox +# def test_set_geolocation_override_with_error(driver): +# """Test setting geolocation override with error.""" +# context_id = driver.current_window_handle +# +# error = GeolocationPositionError() +# +# driver.emulation.set_geolocation_override(error=error, contexts=[context_id]) +# +# # assert "error" after inspecting the get_browser_geolocation script's response