diff --git a/CHANGELOG.md b/CHANGELOG.md index 694b420f..2ac69dca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@
+## v3.4.3 +*Release date: 2026-04-21* + +### Added +- `set_local_storage_item(items)` / `set_session_storage_item(items)` — set one or more key/value pairs in localStorage / sessionStorage +- `get_local_storage_item(key)` / `get_session_storage_item(key)` — retrieve a single item by key (`None` if missing) +- `get_local_storage_items()` / `get_session_storage_items()` — retrieve all items as a dict +- `remove_local_storage_item(key)` / `remove_session_storage_item(key)` — remove a single item by key +- `clear_local_storage()` / `clear_session_storage()` — clear all items from the respective storage +- `MobileDriver.clear_cookies` override — on iOS real devices iterates and deletes each cookie individually via `delete_cookie` instead of a bulk clear +- `storage_set_item_js`, `storage_get_items_js`, `set_cookies_as_batch_js` JS helpers added to `js_scripts.py` + +### Changed +- `CoreDriver.set_cookies` — replaced per-cookie `driver.add_cookie()` loop with a single batched `execute_script` call +- `PlayDriver.set_cookies` — domain extracted via `urlparse` instead of manual string splitting; cookie defaults applied via list comprehension +- `PlayDriver.execute_script` — unified script wrapping to `(args) => (function() { … }).apply(null, args)` for consistent argument passing across all call patterns + +--- + ## v3.4.2 *Release date: 2026-03-28* diff --git a/mops/__init__.py b/mops/__init__.py index 6282fef8..dc7ffed4 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,2 +1,2 @@ -__version__ = '3.4.2' +__version__ = '3.4.3' __project_name__ = 'mops' diff --git a/mops/abstraction/driver_wrapper_abc.py b/mops/abstraction/driver_wrapper_abc.py index 59d433bd..5e9b52aa 100644 --- a/mops/abstraction/driver_wrapper_abc.py +++ b/mops/abstraction/driver_wrapper_abc.py @@ -207,6 +207,106 @@ def get_cookies(self) -> List[dict]: """ raise NotImplementedError() + def set_local_storage_item(self, items: List[dict]) -> DriverWrapper: + """ + Set one or more items in localStorage. + + Each dict must contain ``key`` and ``value`` fields. + + :param items: A list of dicts with ``key`` and ``value``. + :type items: typing.List[dict] + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + + def set_session_storage_item(self, items: List[dict]) -> DriverWrapper: + """ + Set one or more items in sessionStorage. + + Each dict must contain ``key`` and ``value`` fields. + + :param items: A list of dicts with ``key`` and ``value``. + :type items: typing.List[dict] + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + + def get_local_storage_item(self, key: str) -> Union[str, None]: + """ + Retrieve a single item from localStorage by key. + + :param key: The key to look up. + :type key: str + :return: The value string, or :obj:`None` if the key does not exist. + :rtype: typing.Union[str, None] + """ + raise NotImplementedError() + + def get_session_storage_item(self, key: str) -> Union[str, None]: + """ + Retrieve a single item from sessionStorage by key. + + :param key: The key to look up. + :type key: str + :return: The value string, or :obj:`None` if the key does not exist. + :rtype: typing.Union[str, None] + """ + raise NotImplementedError() + + def get_local_storage_items(self) -> dict: + """ + Retrieve all items from localStorage as a dictionary. + + :return: A dict mapping every key to its value. + :rtype: dict + """ + raise NotImplementedError() + + def get_session_storage_items(self) -> dict: + """ + Retrieve all items from sessionStorage as a dictionary. + + :return: A dict mapping every key to its value. + :rtype: dict + """ + raise NotImplementedError() + + def remove_local_storage_item(self, key: str) -> DriverWrapper: + """ + Remove a single item from localStorage by key. + + :param key: The key to remove. + :type key: str + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + + def remove_session_storage_item(self, key: str) -> DriverWrapper: + """ + Remove a single item from sessionStorage by key. + + :param key: The key to remove. + :type key: str + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + + def clear_local_storage(self) -> DriverWrapper: + """ + Remove all items from localStorage. + + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + + def clear_session_storage(self) -> DriverWrapper: + """ + Remove all items from sessionStorage. + + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + raise NotImplementedError() + def switch_to_frame(self, frame: Element) -> DriverWrapper: """ Switch to a specified frame. diff --git a/mops/base/driver_wrapper.py b/mops/base/driver_wrapper.py index 829aec50..185e1dbd 100644 --- a/mops/base/driver_wrapper.py +++ b/mops/base/driver_wrapper.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Union, Type, List, Tuple, TYPE_CHECKING from PIL import Image @@ -11,6 +12,7 @@ BrowserContext as PlaywrightContext, ) +from mops.js_scripts import storage_set_item_js, storage_get_items_js from mops.mixins.objects.box import Box from mops.mixins.objects.driver import Driver from mops.mixins.objects.visual_comaprison_mixin import hide_before_screenshot, reveal_after_screenshot @@ -332,6 +334,112 @@ def soft_assert_screenshot( return True, f'No visual mismatch found for entire screen' + def set_local_storage_item(self, items: List[dict]) -> DriverWrapper: + """ + Set one or more items in localStorage. + + Each dict must contain ``key`` and ``value`` fields. + + :param items: A list of dicts with ``key`` and ``value``. + :type items: typing.List[dict] + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script(storage_set_item_js, items, 'localStorage') + return self + + def set_session_storage_item(self, items: List[dict]) -> DriverWrapper: + """ + Set one or more items in sessionStorage. + + Each dict must contain ``key`` and ``value`` fields. + + :param items: A list of dicts with ``key`` and ``value``. + :type items: typing.List[dict] + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script(storage_set_item_js, items, 'sessionStorage') + return self + + def get_local_storage_item(self, key: str) -> Union[str, None]: + """ + Retrieve a single item from localStorage by key. + + :param key: The key to look up. + :type key: str + :return: The value string, or :obj:`None` if the key does not exist. + :rtype: typing.Union[str, None] + """ + return self.execute_script(f'return localStorage.getItem({json.dumps(key)})') + + def get_session_storage_item(self, key: str) -> Union[str, None]: + """ + Retrieve a single item from sessionStorage by key. + + :param key: The key to look up. + :type key: str + :return: The value string, or :obj:`None` if the key does not exist. + :rtype: typing.Union[str, None] + """ + return self.execute_script(f'return sessionStorage.getItem({json.dumps(key)})') + + def get_local_storage_items(self) -> dict: + """ + Retrieve all items from localStorage as a dictionary. + + :return: A dict mapping every key to its value. + :rtype: dict + """ + return self.execute_script(storage_get_items_js, 'localStorage') + + def get_session_storage_items(self) -> dict: + """ + Retrieve all items from sessionStorage as a dictionary. + + :return: A dict mapping every key to its value. + :rtype: dict + """ + return self.execute_script(storage_get_items_js, 'sessionStorage') + + def remove_local_storage_item(self, key: str) -> DriverWrapper: + """ + Remove a single item from localStorage by key. + + :param key: The key to remove. + :type key: str + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script(f'localStorage.removeItem({json.dumps(key)})') + return self + + def remove_session_storage_item(self, key: str) -> DriverWrapper: + """ + Remove a single item from sessionStorage by key. + + :param key: The key to remove. + :type key: str + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script(f'sessionStorage.removeItem({json.dumps(key)})') + return self + + def clear_local_storage(self) -> DriverWrapper: + """ + Remove all items from localStorage. + + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script('localStorage.clear()') + return self + + def clear_session_storage(self) -> DriverWrapper: + """ + Remove all items from sessionStorage. + + :return: :obj:`.DriverWrapper` - The current instance of the driver wrapper. + """ + self.execute_script('sessionStorage.clear()') + return self + def __init_base_class__(self) -> None: """ Get driver wrapper class in according to given driver source, and set him as base class diff --git a/mops/js_scripts.py b/mops/js_scripts.py index c8fe9f26..4ada8607 100644 --- a/mops/js_scripts.py +++ b/mops/js_scripts.py @@ -2,6 +2,20 @@ get_inner_width_js = 'return window.innerWidth' js_click = 'arguments[0].click();' +storage_set_item_js = """ +arguments[0].forEach(item => { + window[arguments[1]].setItem(item.key, item.value); +}); +""" + +storage_get_items_js = "return Object.assign({}, window[arguments[0]])" + +set_cookies_as_batch_js = """ +arguments[0].forEach(c => { + document.cookie = `${c.name}=${c.value}; path=${c.path}`; +}); +""" + get_element_position_on_screen_js = """ function getPositionOnScreen(elem) { let box = elem.getBoundingClientRect(); diff --git a/mops/playwright/play_driver.py b/mops/playwright/play_driver.py index 093d799c..a9f52a43 100644 --- a/mops/playwright/play_driver.py +++ b/mops/playwright/play_driver.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import asdict +from urllib.parse import urlparse from functools import cached_property from typing import List, Union, Any, TYPE_CHECKING @@ -193,15 +194,15 @@ def set_cookie(self, cookies: List[dict]) -> PlayDriver: :type cookies: typing.List[dict] :return: :obj:`.PlayDriver` - The current instance of the driver wrapper. """ - for cookie in cookies: - - if 'path' not in cookie: - cookie.update({'path': '/'}) - - if 'domain' not in cookie: - cookie.update({'domain': f'.{self.current_url.split("://")[1].split("/")[0]}'}) - - self.context.add_cookies(cookies) + domain = f'.{urlparse(self.current_url).netloc}' + processed = [ + { + **c, + 'path': c.get('path', '/'), + 'domain': c.get('domain', domain) + } for c in cookies + ] + self.context.add_cookies(processed) # noqa return self def clear_cookies(self) -> PlayDriver: @@ -262,17 +263,9 @@ def execute_script(self, script: str, *args: Any) -> Any: :type args: :obj:`typing.Any` :return: :obj:`typing.Any` - The result of the JavaScript execution. """ - script = script.replace('return ', '') - - if 'arguments[0]' in script: - args = [getattr(arg, 'element', arg) for arg in args] - script = f'arguments => {{{script}}}' - - for index, arg in enumerate(args): - if isinstance(arg, Locator): - args[index] = arg.first.element_handle() - - return self.driver.evaluate(script, args) + args = [getattr(arg, 'element', arg) for arg in args] + args = [arg.first.element_handle() if isinstance(arg, Locator) else arg for arg in args] + return self.driver.evaluate(f'(args) => (function() {{ {script} }}).apply(null, args)', list(args)) def evaluate(self, expression: str, arg: Any = None) -> Any: """ diff --git a/mops/selenium/core/core_driver.py b/mops/selenium/core/core_driver.py index 32a772d8..2445df26 100644 --- a/mops/selenium/core/core_driver.py +++ b/mops/selenium/core/core_driver.py @@ -7,7 +7,7 @@ from PIL import Image from appium.webdriver.webdriver import WebDriver as AppiumDriver -from mops.js_scripts import get_inner_height_js, get_inner_width_js +from mops.js_scripts import get_inner_height_js, get_inner_width_js, set_cookies_as_batch_js from mops.mixins.objects.size import Size from mops.shared_utils import _scaled_screenshot from selenium.common.exceptions import WebDriverException as SeleniumWebDriverException, NoAlertPresentException @@ -235,13 +235,8 @@ def set_cookie(self, cookies: List[dict]) -> CoreDriver: :type cookies: typing.List[dict] :return: :obj:`.CoreDriver` - The current instance of the driver wrapper. """ - for cookie in cookies: - - if 'path' not in cookie: - cookie.update({'path': '/'}) - - self.driver.add_cookie(cookie) - + processed = [{**c, 'path': c.get('path', '/')} for c in cookies] + self.driver.execute_script(set_cookies_as_batch_js, processed) return self def clear_cookies(self) -> CoreDriver: diff --git a/mops/selenium/driver/mobile_driver.py b/mops/selenium/driver/mobile_driver.py index c72734d2..b01843e4 100644 --- a/mops/selenium/driver/mobile_driver.py +++ b/mops/selenium/driver/mobile_driver.py @@ -263,6 +263,20 @@ def click_by_coordinates(self, x: int, y: int, silent: bool = False) -> MobileDr return self + def clear_cookies(self) -> MobileDriver: + """ + Delete all cookies in the current session. + + :return: :obj:`.MobileDriver` - The current instance of the driver wrapper. + """ + if self.is_ios and self.is_real_device: + for cookie_name in [cookie['name'] for cookie in self.get_cookies()]: + self.delete_cookie(cookie_name) + else: + CoreDriver.clear_cookies(self) + + return self + def _set_static(obj) -> None: """ diff --git a/tests/conftest.py b/tests/conftest.py index 6881fc69..44b52f60 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from selenium.webdriver.firefox.options import Options as FirefoxOptions from selenium.webdriver.safari.options import Options as SafariOptions -from mops.base.driver_wrapper import DriverWrapper +from mops.base.driver_wrapper import DriverWrapper, DriverWrapperSessions from mops.mixins.objects.driver import Driver from mops.mixins.objects.size import Size from mops.utils.logs import driver_wrapper_logs_settings @@ -102,9 +102,8 @@ def redirect(request): print() yield print() - if DriverWrapper.session.sessions_count() > 0: - dw = request.getfixturevalue('driver_wrapper') - dw.get('data:,', silent=True) + for dw in DriverWrapperSessions.all_sessions: + dw.get('about:blank', silent=True) @pytest.fixture diff --git a/tests/web_tests/test_cookies.py b/tests/web_tests/test_cookies.py new file mode 100644 index 00000000..4aa26744 --- /dev/null +++ b/tests/web_tests/test_cookies.py @@ -0,0 +1,84 @@ +DOMAIN = '.customenv.github.io' + + +def test_set_cookie_sets_cookies(driver_wrapper, second_playground_page): + driver_wrapper.set_cookie([{'name': 'sample_cookie', 'value': '123', 'domain': DOMAIN}]) + cookies = driver_wrapper.get_cookies() + driver_wrapper.clear_cookies() + + assert any(c['name'] == 'sample_cookie' and c['value'] == '123' for c in cookies) + assert not driver_wrapper.get_cookies() + + +def test_set_cookie_multiple(driver_wrapper, second_playground_page): + driver_wrapper.set_cookie([ + {'name': 'cookie_a', 'value': 'aaa', 'domain': DOMAIN}, + {'name': 'cookie_b', 'value': 'bbb', 'domain': DOMAIN}, + ]) + cookies = driver_wrapper.get_cookies() + names = {c['name'] for c in cookies} + driver_wrapper.clear_cookies() + + assert {'cookie_a', 'cookie_b'}.issubset(names) + + +def test_set_cookie_does_not_mutate_input(driver_wrapper, second_playground_page): + cookie = {'name': 'no_mutation', 'value': 'x', 'domain': DOMAIN} + original_keys = set(cookie.keys()) + + driver_wrapper.set_cookie([cookie]) + driver_wrapper.clear_cookies() + + assert set(cookie.keys()) == original_keys + + +def test_set_cookie_default_path(driver_wrapper, second_playground_page): + cookie_without_path = {'name': 'path_cookie', 'value': '1', 'domain': DOMAIN} + + driver_wrapper.set_cookie([cookie_without_path]) + cookies = driver_wrapper.get_cookies() + driver_wrapper.clear_cookies() + + match = next((c for c in cookies if c['name'] == 'path_cookie'), None) + assert match is not None + assert match.get('path') == '/' + + +def test_delete_cookie_removes_target(driver_wrapper, second_playground_page): + driver_wrapper.set_cookie([ + {'name': 'keep_me', 'value': '1', 'domain': DOMAIN}, + {'name': 'delete_me', 'value': '2', 'domain': DOMAIN}, + ]) + driver_wrapper.delete_cookie('delete_me') + remaining = driver_wrapper.get_cookies() + names = {c['name'] for c in remaining} + driver_wrapper.clear_cookies() + + assert 'delete_me' not in names + assert 'keep_me' in names + + +def test_delete_cookie_leaves_others_intact(driver_wrapper, second_playground_page): + driver_wrapper.set_cookie([ + {'name': 'alpha', 'value': 'aaa', 'domain': DOMAIN}, + {'name': 'beta', 'value': 'bbb', 'domain': DOMAIN}, + {'name': 'gamma', 'value': 'ccc', 'domain': DOMAIN}, + ]) + driver_wrapper.delete_cookie('beta') + remaining = driver_wrapper.get_cookies() + names = {c['name'] for c in remaining} + driver_wrapper.clear_cookies() + + assert 'beta' not in names + assert {'alpha', 'gamma'}.issubset(names) + + +def test_delete_cookie_only_one(driver_wrapper, second_playground_page): + driver_wrapper.set_cookie([{'name': 'solo', 'value': 'x', 'domain': DOMAIN}]) + driver_wrapper.delete_cookie('solo') + + assert not driver_wrapper.get_cookies() + + +def test_delete_nonexistent_cookie_does_not_raise(driver_wrapper, second_playground_page): + driver_wrapper.delete_cookie('nonexistent_cookie') diff --git a/tests/web_tests/test_driver.py b/tests/web_tests/test_driver.py index 4e70bd92..9df1abc6 100644 --- a/tests/web_tests/test_driver.py +++ b/tests/web_tests/test_driver.py @@ -28,38 +28,6 @@ def test_is_firefox_driver(driver_wrapper): assert not driver_wrapper.is_chrome -def test_driver_cookies(driver_wrapper, mouse_event_page_v2): - driver_wrapper.set_cookie( - [ - {'name': 'sample_cookie', 'value': '123', 'path': '/', 'domain': '.customenv.github.io'} - ] - ) - - actual_cookies_after_set = driver_wrapper.get_cookies() - driver_wrapper.clear_cookies() - actual_cookies_after_clear = driver_wrapper.get_cookies() - - assert all((actual_cookies_after_set, not actual_cookies_after_clear)) - - -def test_driver_delete_cookie(driver_wrapper, mouse_event_page_v2): - cookie_name_1 = 'sample_cookie' - cookie_name_2 = 'another_cookie' - driver_wrapper.set_cookie( - [ - {'name': cookie_name_1, 'value': '123', 'path': '/', 'domain': '.customenv.github.io'}, - {'name': cookie_name_2, 'value': '321', 'path': '/', 'domain': '.customenv.github.io'}, - ] - ) - actual_cookies_after_set = driver_wrapper.get_cookies() - driver_wrapper.delete_cookie(cookie_name_2) - actual_cookies_after_clear = driver_wrapper.get_cookies() - - assert len(actual_cookies_after_set) == 2 - assert len(actual_cookies_after_clear) == 1 - assert actual_cookies_after_clear[0]['name'] == cookie_name_1 - - def test_driver_execute_script_set_and_get(driver_wrapper, mouse_event_page_v2): driver_wrapper.execute_script('sessionStorage.setItem("foo", "bar")') assert driver_wrapper.execute_script('return sessionStorage.getItem("foo")') == 'bar' diff --git a/tests/web_tests/test_storage.py b/tests/web_tests/test_storage.py new file mode 100644 index 00000000..85f430d4 --- /dev/null +++ b/tests/web_tests/test_storage.py @@ -0,0 +1,82 @@ +def test_set_local_storage_item(driver_wrapper, second_playground_page): + driver_wrapper.set_local_storage_item([{'key': 'ls_key', 'value': 'ls_value'}]) + + assert driver_wrapper.get_local_storage_item('ls_key') == 'ls_value' + + +def test_set_local_storage_items_multiple(driver_wrapper, second_playground_page): + driver_wrapper.set_local_storage_item([ + {'key': 'ls_a', 'value': 'aaa'}, + {'key': 'ls_b', 'value': 'bbb'}, + ]) + items = driver_wrapper.get_local_storage_items() + + assert items.get('ls_a') == 'aaa' + assert items.get('ls_b') == 'bbb' + + +def test_get_local_storage_item_missing(driver_wrapper, second_playground_page): + assert driver_wrapper.get_local_storage_item('nonexistent_key') is None + + +def test_set_session_storage_item(driver_wrapper, second_playground_page): + driver_wrapper.set_session_storage_item([{'key': 'ss_key', 'value': 'ss_value'}]) + + assert driver_wrapper.get_session_storage_item('ss_key') == 'ss_value' + + +def test_set_session_storage_items_multiple(driver_wrapper, second_playground_page): + driver_wrapper.set_session_storage_item([ + {'key': 'ss_a', 'value': 'aaa'}, + {'key': 'ss_b', 'value': 'bbb'}, + ]) + items = driver_wrapper.get_session_storage_items() + + assert items.get('ss_a') == 'aaa' + assert items.get('ss_b') == 'bbb' + + +def test_get_session_storage_item_missing(driver_wrapper, second_playground_page): + assert driver_wrapper.get_session_storage_item('nonexistent_key') is None + + +def test_remove_local_storage_item(driver_wrapper, second_playground_page): + driver_wrapper.set_local_storage_item([ + {'key': 'ls_keep', 'value': '1'}, + {'key': 'ls_remove', 'value': '2'}, + ]) + driver_wrapper.remove_local_storage_item('ls_remove') + + assert driver_wrapper.get_local_storage_item('ls_keep') == '1' + assert driver_wrapper.get_local_storage_item('ls_remove') is None + + +def test_remove_session_storage_item(driver_wrapper, second_playground_page): + driver_wrapper.set_session_storage_item([ + {'key': 'ss_keep', 'value': '1'}, + {'key': 'ss_remove', 'value': '2'}, + ]) + driver_wrapper.remove_session_storage_item('ss_remove') + + assert driver_wrapper.get_session_storage_item('ss_keep') == '1' + assert driver_wrapper.get_session_storage_item('ss_remove') is None + + +def test_clear_local_storage(driver_wrapper, second_playground_page): + driver_wrapper.set_local_storage_item([ + {'key': 'ls_a', 'value': 'aaa'}, + {'key': 'ls_b', 'value': 'bbb'}, + ]) + driver_wrapper.clear_local_storage() + + assert driver_wrapper.get_local_storage_items() == {} + + +def test_clear_session_storage(driver_wrapper, second_playground_page): + driver_wrapper.set_session_storage_item([ + {'key': 'ss_a', 'value': 'aaa'}, + {'key': 'ss_b', 'value': 'bbb'}, + ]) + driver_wrapper.clear_session_storage() + + assert driver_wrapper.get_session_storage_items() == {}