diff --git a/py/selenium/webdriver/common/bidi/storage.py b/py/selenium/webdriver/common/bidi/storage.py new file mode 100644 index 0000000000000..b531054ea689e --- /dev/null +++ b/py/selenium/webdriver/common/bidi/storage.py @@ -0,0 +1,418 @@ +# 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 typing import Optional +from typing import Union + +from selenium.webdriver.common.bidi.common import command_builder + + +class SameSite: + """Represents the possible same site values for cookies.""" + + STRICT = "strict" + LAX = "lax" + NONE = "none" + + +class BytesValue: + """Represents a bytes value.""" + + TYPE_BASE64 = "base64" + TYPE_STRING = "string" + + def __init__(self, type: str, value: str): + self.type = type + self.value = value + + def to_dict(self) -> Dict: + """Converts the BytesValue to a dictionary. + + Returns: + ------- + Dict: A dictionary representation of the BytesValue. + """ + return {"type": self.type, "value": self.value} + + +class Cookie: + """Represents a cookie.""" + + def __init__( + self, + name: str, + value: BytesValue, + domain: str, + path: Optional[str] = None, + size: Optional[int] = None, + http_only: Optional[bool] = None, + secure: Optional[bool] = None, + same_site: Optional[str] = None, + expiry: Optional[int] = None, + ): + self.name = name + self.value = value + self.domain = domain + self.path = path + self.size = size + self.http_only = http_only + self.secure = secure + self.same_site = same_site + self.expiry = expiry + + @classmethod + def from_dict(cls, data: Dict) -> "Cookie": + """Creates a Cookie instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the cookie information. + + Returns: + ------- + Cookie: A new instance of Cookie. + """ + value = BytesValue(data.get("value", {}).get("type"), data.get("value", {}).get("value")) + + return cls( + name=data.get("name"), + value=value, + domain=data.get("domain"), + path=data.get("path"), + size=data.get("size"), + http_only=data.get("httpOnly"), + secure=data.get("secure"), + same_site=data.get("sameSite"), + expiry=data.get("expiry"), + ) + + +class CookieFilter: + """Represents a filter for cookies.""" + + def __init__( + self, + name: Optional[str] = None, + value: Optional[BytesValue] = None, + domain: Optional[str] = None, + path: Optional[str] = None, + size: Optional[int] = None, + http_only: Optional[bool] = None, + secure: Optional[bool] = None, + same_site: Optional[str] = None, + expiry: Optional[int] = None, + ): + self.name = name + self.value = value + self.domain = domain + self.path = path + self.size = size + self.http_only = http_only + self.secure = secure + self.same_site = same_site + self.expiry = expiry + + def to_dict(self) -> Dict: + """Converts the CookieFilter to a dictionary. + + Returns: + ------- + Dict: A dictionary representation of the CookieFilter. + """ + result = {} + if self.name is not None: + result["name"] = self.name + if self.value is not None: + result["value"] = self.value.to_dict() + if self.domain is not None: + result["domain"] = self.domain + if self.path is not None: + result["path"] = self.path + if self.size is not None: + result["size"] = self.size + if self.http_only is not None: + result["httpOnly"] = self.http_only + if self.secure is not None: + result["secure"] = self.secure + if self.same_site is not None: + result["sameSite"] = self.same_site + if self.expiry is not None: + result["expiry"] = self.expiry + return result + + +class PartitionKey: + """Represents a storage partition key.""" + + def __init__(self, user_context: Optional[str] = None, source_origin: Optional[str] = None): + self.user_context = user_context + self.source_origin = source_origin + + @classmethod + def from_dict(cls, data: Dict) -> "PartitionKey": + """Creates a PartitionKey instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the partition key information. + + Returns: + ------- + PartitionKey: A new instance of PartitionKey. + """ + return cls( + user_context=data.get("userContext"), + source_origin=data.get("sourceOrigin"), + ) + + +class BrowsingContextPartitionDescriptor: + """Represents a browsing context partition descriptor.""" + + def __init__(self, context: str): + self.type = "context" + self.context = context + + def to_dict(self) -> Dict: + """Converts the BrowsingContextPartitionDescriptor to a dictionary. + + Returns: + ------- + Dict: A dictionary representation of the BrowsingContextPartitionDescriptor. + """ + return {"type": self.type, "context": self.context} + + +class StorageKeyPartitionDescriptor: + """Represents a storage key partition descriptor.""" + + def __init__(self, user_context: Optional[str] = None, source_origin: Optional[str] = None): + self.type = "storageKey" + self.user_context = user_context + self.source_origin = source_origin + + def to_dict(self) -> Dict: + """Converts the StorageKeyPartitionDescriptor to a dictionary. + + Returns: + ------- + Dict: A dictionary representation of the StorageKeyPartitionDescriptor. + """ + result = {"type": self.type} + if self.user_context is not None: + result["userContext"] = self.user_context + if self.source_origin is not None: + result["sourceOrigin"] = self.source_origin + return result + + +class PartialCookie: + """Represents a partial cookie for setting.""" + + def __init__( + self, + name: str, + value: BytesValue, + domain: str, + path: Optional[str] = None, + http_only: Optional[bool] = None, + secure: Optional[bool] = None, + same_site: Optional[str] = None, + expiry: Optional[int] = None, + ): + self.name = name + self.value = value + self.domain = domain + self.path = path + self.http_only = http_only + self.secure = secure + self.same_site = same_site + self.expiry = expiry + + def to_dict(self) -> Dict: + """Converts the PartialCookie to a dictionary. + + Returns: + ------- + Dict: A dictionary representation of the PartialCookie. + """ + result = { + "name": self.name, + "value": self.value.to_dict(), + "domain": self.domain, + } + if self.path is not None: + result["path"] = self.path + if self.http_only is not None: + result["httpOnly"] = self.http_only + if self.secure is not None: + result["secure"] = self.secure + if self.same_site is not None: + result["sameSite"] = self.same_site + if self.expiry is not None: + result["expiry"] = self.expiry + return result + + +class GetCookiesResult: + """Represents the result of a getCookies command.""" + + def __init__(self, cookies: List[Cookie], partition_key: PartitionKey): + self.cookies = cookies + self.partition_key = partition_key + + @classmethod + def from_dict(cls, data: Dict) -> "GetCookiesResult": + """Creates a GetCookiesResult instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the get cookies result information. + + Returns: + ------- + GetCookiesResult: A new instance of GetCookiesResult. + """ + cookies = [Cookie.from_dict(cookie) for cookie in data.get("cookies", [])] + partition_key = PartitionKey.from_dict(data.get("partitionKey", {})) + return cls(cookies=cookies, partition_key=partition_key) + + +class SetCookieResult: + """Represents the result of a setCookie command.""" + + def __init__(self, partition_key: PartitionKey): + self.partition_key = partition_key + + @classmethod + def from_dict(cls, data: Dict) -> "SetCookieResult": + """Creates a SetCookieResult instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the set cookie result information. + + Returns: + ------- + SetCookieResult: A new instance of SetCookieResult. + """ + partition_key = PartitionKey.from_dict(data.get("partitionKey", {})) + return cls(partition_key=partition_key) + + +class DeleteCookiesResult: + """Represents the result of a deleteCookies command.""" + + def __init__(self, partition_key: PartitionKey): + self.partition_key = partition_key + + @classmethod + def from_dict(cls, data: Dict) -> "DeleteCookiesResult": + """Creates a DeleteCookiesResult instance from a dictionary. + + Parameters: + ----------- + data: A dictionary containing the delete cookies result information. + + Returns: + ------- + DeleteCookiesResult: A new instance of DeleteCookiesResult. + """ + partition_key = PartitionKey.from_dict(data.get("partitionKey", {})) + return cls(partition_key=partition_key) + + +class Storage: + """BiDi implementation of the storage module.""" + + def __init__(self, conn): + self.conn = conn + + def get_cookies( + self, + filter: Optional[CookieFilter] = None, + partition: Optional[Union[BrowsingContextPartitionDescriptor, StorageKeyPartitionDescriptor]] = None, + ) -> GetCookiesResult: + """Retrieves cookies that match the given parameters. + + Parameters: + ----------- + filter: Optional filter to match cookies. + partition: Optional partition descriptor. + + Returns: + ------- + GetCookiesResult: The result of the get cookies command. + """ + params = {} + if filter is not None: + params["filter"] = filter.to_dict() + if partition is not None: + params["partition"] = partition.to_dict() + + result = self.conn.execute(command_builder("storage.getCookies", params)) + return GetCookiesResult.from_dict(result) + + def set_cookie( + self, + cookie: PartialCookie, + partition: Optional[Union[BrowsingContextPartitionDescriptor, StorageKeyPartitionDescriptor]] = None, + ) -> SetCookieResult: + """Sets a cookie in the browser. + + Parameters: + ----------- + cookie: The cookie to set. + partition: Optional partition descriptor. + + Returns: + ------- + SetCookieResult: The result of the set cookie command. + """ + params = {"cookie": cookie.to_dict()} + if partition is not None: + params["partition"] = partition.to_dict() + + result = self.conn.execute(command_builder("storage.setCookie", params)) + return SetCookieResult.from_dict(result) + + def delete_cookies( + self, + filter: Optional[CookieFilter] = None, + partition: Optional[Union[BrowsingContextPartitionDescriptor, StorageKeyPartitionDescriptor]] = None, + ) -> DeleteCookiesResult: + """Deletes cookies that match the given parameters. + + Parameters: + ----------- + filter: Optional filter to match cookies to delete. + partition: Optional partition descriptor. + + Returns: + ------- + DeleteCookiesResult: The result of the delete cookies command. + """ + params = {} + if filter is not None: + params["filter"] = filter.to_dict() + if partition is not None: + params["partition"] = partition.to_dict() + + result = self.conn.execute(command_builder("storage.deleteCookies", params)) + return DeleteCookiesResult.from_dict(result) diff --git a/py/selenium/webdriver/remote/webdriver.py b/py/selenium/webdriver/remote/webdriver.py index ff7c86b582f6d..ca7940967562b 100644 --- a/py/selenium/webdriver/remote/webdriver.py +++ b/py/selenium/webdriver/remote/webdriver.py @@ -48,6 +48,7 @@ from selenium.webdriver.common.bidi.network import Network from selenium.webdriver.common.bidi.script import Script from selenium.webdriver.common.bidi.session import Session +from selenium.webdriver.common.bidi.storage import Storage from selenium.webdriver.common.by import By from selenium.webdriver.common.options import ArgOptions from selenium.webdriver.common.options import BaseOptions @@ -266,6 +267,7 @@ def __init__( self._browser = None self._bidi_session = None self._browsing_context = None + self._storage = None def __repr__(self): return f'<{type(self).__module__}.{type(self).__name__} (session="{self.session_id}")>' @@ -1317,6 +1319,29 @@ def browsing_context(self): return self._browsing_context + @property + def storage(self): + """Returns a storage module object for BiDi storage commands. + + Returns: + -------- + Storage: an object containing access to BiDi storage commands. + + Examples: + --------- + >>> cookie_filter = CookieFilter(name="example") + >>> result = driver.storage.get_cookies(filter=cookie_filter) + >>> driver.storage.set_cookie(cookie=PartialCookie("name", BytesValue(BytesValue.TYPE_STRING, "value"), "domain")) + >>> driver.storage.delete_cookies(filter=CookieFilter(name="example")) + """ + if not self._websocket_connection: + self._start_bidi() + + if self._storage is None: + self._storage = Storage(self._websocket_connection) + + return self._storage + def _get_cdp_details(self): import json diff --git a/py/test/selenium/webdriver/common/bidi_storage_tests.py b/py/test/selenium/webdriver/common/bidi_storage_tests.py new file mode 100644 index 0000000000000..bc07b76d7ac85 --- /dev/null +++ b/py/test/selenium/webdriver/common/bidi_storage_tests.py @@ -0,0 +1,355 @@ +# 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 random +import time + +import pytest + +from selenium.webdriver.common.bidi.storage import BrowsingContextPartitionDescriptor +from selenium.webdriver.common.bidi.storage import BytesValue +from selenium.webdriver.common.bidi.storage import CookieFilter +from selenium.webdriver.common.bidi.storage import PartialCookie +from selenium.webdriver.common.bidi.storage import SameSite +from selenium.webdriver.common.bidi.storage import StorageKeyPartitionDescriptor +from selenium.webdriver.common.window import WindowTypes + + +def generate_unique_key(): + return f"key_{random.randint(0, 100000)}" + + +def assert_cookie_is_not_present_with_name(driver, key): + assert driver.get_cookie(key) is None + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert key + "=" not in document_cookie + + +def assert_cookie_is_present_with_name(driver, key): + assert driver.get_cookie(key) is not None + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert key + "=" in document_cookie + + +def assert_cookie_has_value(driver, key, value): + assert driver.get_cookie(key)["value"] == value + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert f"{key}={value}" in document_cookie + + +def assert_no_cookies_are_present(driver): + assert len(driver.get_cookies()) == 0 + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert document_cookie == "" + + +def assert_some_cookies_are_present(driver): + assert len(driver.get_cookies()) > 0 + document_cookie = get_document_cookie_or_none(driver) + if document_cookie is not None: + assert document_cookie != "" + + +def get_document_cookie_or_none(driver): + try: + return driver.execute_script("return document.cookie") + except Exception: + return None + + +class TestBidiStorage: + + @pytest.fixture(autouse=True) + def setup(self, driver, pages): + driver.get(pages.url("simpleTest.html")) + driver.delete_all_cookies() + + def test_storage_initialized(self, driver): + """Test that the storage module is initialized properly.""" + assert driver.storage is not None + + def test_get_cookie_by_name(self, driver, pages, webserver): + """Test getting a cookie by name.""" + assert_no_cookies_are_present(driver) + + key = generate_unique_key() + value = "set" + assert_cookie_is_not_present_with_name(driver, key) + + driver.add_cookie({"name": key, "value": value}) + + # Test + cookie_filter = CookieFilter(name=key, value=BytesValue(BytesValue.TYPE_STRING, "set")) + + result = driver.storage.get_cookies(filter=cookie_filter) + + # Verify + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + + @pytest.mark.xfail_chrome + @pytest.mark.xfail_edge + def test_get_cookie_in_default_user_context(self, driver, pages, webserver): + """Test getting a cookie in the default user context.""" + assert_no_cookies_are_present(driver) + + window_handle = driver.current_window_handle + key = generate_unique_key() + value = "set" + assert_cookie_is_not_present_with_name(driver, key) + + driver.add_cookie({"name": key, "value": value}) + + # Test + cookie_filter = CookieFilter(name=key, value=BytesValue(BytesValue.TYPE_STRING, "set")) + + driver.switch_to.new_window(WindowTypes.WINDOW) + + descriptor = BrowsingContextPartitionDescriptor(driver.current_window_handle) + + params = cookie_filter + result_after_switching_context = driver.storage.get_cookies(filter=params, partition=descriptor) + + assert len(result_after_switching_context.cookies) > 0 + assert result_after_switching_context.cookies[0].value.value == value + + driver.switch_to.window(window_handle) + + descriptor = BrowsingContextPartitionDescriptor(driver.current_window_handle) + + result = driver.storage.get_cookies(filter=cookie_filter, partition=descriptor) + + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + partition_key = result.partition_key + + assert partition_key.source_origin is not None + assert partition_key.user_context is not None + assert partition_key.user_context == "default" + + def test_get_cookie_in_a_user_context(self, driver, pages, webserver): + """Test getting a cookie in a user context.""" + assert_no_cookies_are_present(driver) + + user_context = driver.browser.create_user_context() + window_handle = driver.current_window_handle + + key = generate_unique_key() + value = "set" + + descriptor = StorageKeyPartitionDescriptor(user_context=user_context) + + parameters = PartialCookie(key, BytesValue(BytesValue.TYPE_STRING, value), webserver.host) + + driver.storage.set_cookie(cookie=parameters, partition=descriptor) + + # Test + cookie_filter = CookieFilter(name=key, value=BytesValue(BytesValue.TYPE_STRING, "set")) + + # Create a new window with the user context + new_window = driver.browsing_context.create(type=WindowTypes.TAB, user_context=user_context) + + driver.switch_to.window(new_window) + + result = driver.storage.get_cookies(filter=cookie_filter, partition=descriptor) + + assert len(result.cookies) > 0 + assert result.cookies[0].value.value == value + partition_key = result.partition_key + + assert partition_key.user_context is not None + assert partition_key.user_context == user_context + + driver.switch_to.window(window_handle) + + browsing_context_partition_descriptor = BrowsingContextPartitionDescriptor(window_handle) + + result1 = driver.storage.get_cookies(filter=cookie_filter, partition=browsing_context_partition_descriptor) + + assert len(result1.cookies) == 0 + + # Clean up + driver.browsing_context.close(new_window) + driver.browser.remove_user_context(user_context) + + def test_add_cookie(self, driver, pages, webserver): + """Test adding a cookie.""" + assert_no_cookies_are_present(driver) + + key = generate_unique_key() + value = "foo" + + parameters = PartialCookie(key, BytesValue(BytesValue.TYPE_STRING, value), webserver.host) + assert_cookie_is_not_present_with_name(driver, key) + + # Test + driver.storage.set_cookie(cookie=parameters) + + # Verify + assert_cookie_has_value(driver, key, value) + driver.get(pages.url("simpleTest.html")) + assert_cookie_has_value(driver, key, value) + + @pytest.mark.xfail_chrome + @pytest.mark.xfail_edge + def test_add_and_get_cookie(self, driver, pages, webserver): + """Test adding and getting a cookie with all parameters.""" + assert_no_cookies_are_present(driver) + + value = BytesValue(BytesValue.TYPE_STRING, "cod") + domain = webserver.host + + expiry = int(time.time() + 3600) + + path = "/simpleTest.html" + + cookie = PartialCookie( + "fish", value, domain, path=path, http_only=True, secure=False, same_site=SameSite.LAX, expiry=expiry + ) + + # Test + driver.storage.set_cookie(cookie=cookie) + + driver.get(pages.url("simpleTest.html")) + + cookie_filter = CookieFilter( + name="fish", + value=value, + domain=domain, + path=path, + http_only=True, + secure=False, + same_site=SameSite.LAX, + expiry=expiry, + ) + + descriptor = BrowsingContextPartitionDescriptor(driver.current_window_handle) + + result = driver.storage.get_cookies(filter=cookie_filter, partition=descriptor) + key = result.partition_key + + # Verify + assert len(result.cookies) > 0 + result_cookie = result.cookies[0] + + assert result_cookie.name == "fish" + assert result_cookie.value.value == value.value + assert result_cookie.domain == domain + assert result_cookie.path == path + assert result_cookie.http_only is True + assert result_cookie.secure is False + assert result_cookie.same_site == SameSite.LAX + assert result_cookie.expiry == expiry + assert key.source_origin is not None + assert key.user_context is not None + assert key.user_context == "default" + + @pytest.mark.xfail_edge + def test_get_all_cookies(self, driver, pages, webserver): + """Test getting all cookies.""" + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_not_present_with_name(driver, key2) + + # Test + params = CookieFilter() + result = driver.storage.get_cookies(filter=params) + + count_before = len(result.cookies) + + driver.add_cookie({"name": key1, "value": "value"}) + driver.add_cookie({"name": key2, "value": "value"}) + + driver.get(pages.url("simpleTest.html")) + result = driver.storage.get_cookies(filter=params) + + # Verify + assert len(result.cookies) == count_before + 2 + cookie_names = [cookie.name for cookie in result.cookies] + assert key1 in cookie_names + assert key2 in cookie_names + + def test_delete_all_cookies(self, driver, pages, webserver): + """Test deleting all cookies.""" + assert_no_cookies_are_present(driver) + + driver.add_cookie({"name": "foo", "value": "set"}) + assert_some_cookies_are_present(driver) + + # Test + driver.storage.delete_cookies(filter=CookieFilter()) + + # Verify + assert_no_cookies_are_present(driver) + + driver.get(pages.url("simpleTest.html")) + assert_no_cookies_are_present(driver) + + def test_delete_cookie_with_name(self, driver, pages, webserver): + """Test deleting a cookie with a specific name.""" + assert_no_cookies_are_present(driver) + + key1 = generate_unique_key() + key2 = generate_unique_key() + + driver.add_cookie({"name": key1, "value": "set"}) + driver.add_cookie({"name": key2, "value": "set"}) + + assert_cookie_is_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + + # Test + driver.storage.delete_cookies(filter=CookieFilter(name=key1)) + + # Verify + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + + driver.get(pages.url("simpleTest.html")) + assert_cookie_is_not_present_with_name(driver, key1) + assert_cookie_is_present_with_name(driver, key2) + + def test_add_cookies_with_different_paths(self, driver, pages, webserver): + """Test adding cookies with different paths that are related to ours.""" + assert_no_cookies_are_present(driver) + + cookie1 = PartialCookie( + "fish", BytesValue(BytesValue.TYPE_STRING, "cod"), webserver.host, path="/simpleTest.html" + ) + + cookie2 = PartialCookie("planet", BytesValue(BytesValue.TYPE_STRING, "earth"), webserver.host, path="/") + + # Test + driver.storage.set_cookie(cookie=cookie1) + driver.storage.set_cookie(cookie=cookie2) + + driver.get(pages.url("simpleTest.html")) + + # Verify + assert_cookie_is_present_with_name(driver, "fish") + assert_cookie_is_present_with_name(driver, "planet") + + driver.get(pages.url("formPage.html")) + assert_cookie_is_not_present_with_name(driver, "fish")