From a6b1bd46520ad0e93b3c5fd8df2e61e2e97e2f68 Mon Sep 17 00:00:00 2001 From: yashaka Date: Thu, 24 Oct 2024 13:36:47 +0300 Subject: [PATCH] NEW: appium device support (POC) --- .../__init__.py | 0 .../run_cross_platform_android_ios/project.py | 83 ++ .../tests/__init__.py | 0 .../tests/acceptance_test.py | 22 + .../tests/conftest.py | 17 + .../wikipedia_app_tests/__init__.py | 0 .../wikipedia_app_tests/support/__init__.py | 2 + .../support/mobile_selectors.py | 110 +++ .../wikipedia_app_tests/support/path.py | 10 + poetry.lock | 18 +- pyproject.toml | 2 +- selene/core/_context.py | 108 +++ selene/core/command.py | 47 +- selene/core/entity.py | 21 + selene/core/entity.pyi | 4 + selene/core/locator.py | 6 +- selene/core/match.py | 27 +- selene/core/query.py | 32 +- selene/support/_mobile/__init__.py | 34 + selene/support/_mobile/context.py | 206 +++++ selene/support/_mobile/elements.py | 765 ++++++++++++++++++ selene/support/_mobile/locators.py | 116 +++ 22 files changed, 1570 insertions(+), 60 deletions(-) create mode 100644 examples/run_cross_platform_android_ios/__init__.py create mode 100644 examples/run_cross_platform_android_ios/project.py create mode 100644 examples/run_cross_platform_android_ios/tests/__init__.py create mode 100644 examples/run_cross_platform_android_ios/tests/acceptance_test.py create mode 100644 examples/run_cross_platform_android_ios/tests/conftest.py create mode 100644 examples/run_cross_platform_android_ios/wikipedia_app_tests/__init__.py create mode 100644 examples/run_cross_platform_android_ios/wikipedia_app_tests/support/__init__.py create mode 100644 examples/run_cross_platform_android_ios/wikipedia_app_tests/support/mobile_selectors.py create mode 100644 examples/run_cross_platform_android_ios/wikipedia_app_tests/support/path.py create mode 100644 selene/core/_context.py create mode 100644 selene/support/_mobile/__init__.py create mode 100644 selene/support/_mobile/context.py create mode 100644 selene/support/_mobile/elements.py create mode 100644 selene/support/_mobile/locators.py diff --git a/examples/run_cross_platform_android_ios/__init__.py b/examples/run_cross_platform_android_ios/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/run_cross_platform_android_ios/project.py b/examples/run_cross_platform_android_ios/project.py new file mode 100644 index 000000000..177d8bda8 --- /dev/null +++ b/examples/run_cross_platform_android_ios/project.py @@ -0,0 +1,83 @@ +from enum import Enum +from typing import Optional + +import dotenv +import pydantic + +from examples.run_cross_platform_android_ios.wikipedia_app_tests.support import path + + +class EnvContext(Enum): + android = 'android' + ios = 'ios' + # bstack_android = 'bstack_android' + # bstack_ios = 'bstack_ios' + # local_android = 'local_android' + # local = 'local' + # local_ios = 'local_ios' + + +class Config(pydantic.BaseSettings): + context: EnvContext = EnvContext.android + driver_remote_url: str = 'http://127.0.0.1:4723' + + app_package: str = 'org.wikipedia.alpha' + app = './app-alpha-universal-release.apk' + appWaitActivity = 'org.wikipedia.*' + deviceName: Optional[str] = None + bstack_userName: Optional[str] = None + bstack_accessKey: Optional[str] = None + platformVersion: Optional[str] = None + + @property + def bstack_creds(self): + return { + 'userName': self.bstack_userName, + 'accessKey': self.bstack_accessKey, + } + + @property + def runs_on_bstack(self): + return self.app.startswith('bs://') + + def to_driver_options(self): + if self.context is EnvContext.android: + from appium.options.android import UiAutomator2Options + + options = UiAutomator2Options() + + if self.deviceName: + options.set_capability('deviceName', self.deviceName) + + if self.appWaitActivity: + options.set_capability('appWaitActivity', self.appWaitActivity) + + options.set_capability( + 'app', + ( + self.app + if (self.app.startswith('/') or self.runs_on_bstack) + else path.relative_from_root(self.app) + ), + ) + + if self.platformVersion: + options.set_capability('platformVersion', self.platformVersion) + + if self.runs_on_bstack: + options.set_capability( + 'bstack:options', + { + 'projectName': 'Wikipedia App Tests', + 'buildName': 'browserstack-build-1', # TODO: use some unique value + 'sessionName': 'BStack first_test', # TODO: use some unique value + **self.bstack_creds, + }, + ) + + return options + else: + raise ValueError(f'Unsupported context: {self.context}') + + +config = Config(dotenv.find_dotenv()) # type: ignore diff --git a/examples/run_cross_platform_android_ios/tests/__init__.py b/examples/run_cross_platform_android_ios/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/run_cross_platform_android_ios/tests/acceptance_test.py b/examples/run_cross_platform_android_ios/tests/acceptance_test.py new file mode 100644 index 000000000..7bdc8dbe2 --- /dev/null +++ b/examples/run_cross_platform_android_ios/tests/acceptance_test.py @@ -0,0 +1,22 @@ +from selene import have, be +from selene.support._mobile import device + + +def test_wikipedia_searches(): + # GIVEN + device.element('fragment_onboarding_skip_button').tap() + + # WHEN + device.element(drd='Search Wikipedia').tap() + device.element('search_src_text').type('Appium') + + # THEN + results = device.all('page_list_item_title') + results.should(have.size_greater_than(0)) + results.first.should(have.text('Appium')) + + # WHEN + results.first.tap() + + # THEN + device.element('text=Appium').should(be.visible) diff --git a/examples/run_cross_platform_android_ios/tests/conftest.py b/examples/run_cross_platform_android_ios/tests/conftest.py new file mode 100644 index 000000000..aae553b6e --- /dev/null +++ b/examples/run_cross_platform_android_ios/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from examples.run_cross_platform_android_ios import project +from examples.run_cross_platform_android_ios.wikipedia_app_tests import support +from selene.support._mobile import device + + +@pytest.fixture(scope='function', autouse=True) +def driver_management(): + device.config.driver_options = project.config.to_driver_options() + device.config.driver_remote_url = project.config.driver_remote_url + device.config.selector_to_by_strategy = support.mobile_selectors.to_by_strategy + device.config.timeout = 8.0 + + yield + + device.driver.quit() diff --git a/examples/run_cross_platform_android_ios/wikipedia_app_tests/__init__.py b/examples/run_cross_platform_android_ios/wikipedia_app_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/__init__.py b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/__init__.py new file mode 100644 index 000000000..e98febba2 --- /dev/null +++ b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/__init__.py @@ -0,0 +1,2 @@ +from . import mobile_selectors +from . import path diff --git a/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/mobile_selectors.py b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/mobile_selectors.py new file mode 100644 index 000000000..549251c0f --- /dev/null +++ b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/mobile_selectors.py @@ -0,0 +1,110 @@ +import re +from binascii import Error + +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.common.by import By + +from examples.run_cross_platform_android_ios import project + + +def is_android_id(selector): + return re.match(r'^[a-zA-Z0-9-_]+(\.[a-zA-Z0-9-_]+)+:id/[a-zA-Z0-9-_]+$', selector) + + +def is_word_with_dashes_underscores_or_numbers(selector): + return re.match(r'^[a-zA-Z_\d\-]+$', selector) + + +def are_words_with_dashes_underscores_or_numbers_separated_by_space(selector): + return re.match(r'^[a-zA-Z_\d\- ]+$', selector) + + +def is_xpath_like(selector: str): + return ( + selector.startswith('/') + or selector.startswith('./') + or selector.startswith('..') + or selector.startswith('(') + or selector.startswith('*/') + ) + + +def to_app_package_wise_by(selector: str): + return AppiumBy.ID, ( + f'{project.config.app_package}:id/{selector}' + if project.config.app_package + else selector + ) + + +def to_by_strategy(selector: str): + if is_xpath_like(selector): + return By.XPATH, selector + + # BY EXPLICIT ANDROID ID + if selector.startswith('#') and is_word_with_dashes_underscores_or_numbers( + selector[1:] + ): + if project.config.context is project.EnvContext.android: + return to_app_package_wise_by(selector[1:]) + else: + raise Error( + f'Unsupported selector: {selector}, for platform: {project.config.context}' + ) + + # BY MATCHED ANDROID ID + if is_android_id(selector): + return AppiumBy.ID, selector + + # BY EXACT TEXT + if (selector.startswith('text="') and selector.endswith('"')) or ( + selector.startswith('text=`\'') and selector.endswith('\'') + ): + return ( + ( + AppiumBy.ANDROID_UIAUTOMATOR, + f'new UiSelector().text("{selector[6:-1]}")', + ) + if project.config.context is project.EnvContext.android + else (AppiumBy.IOS_PREDICATE, f'label == "{selector[6:-1]}"') + ) + + # BY PARTIAL TEXT + if selector.startswith('text='): + return ( + ( + AppiumBy.ANDROID_UIAUTOMATOR, + f'new UiSelector().textContains("{selector[5:]}")', + ) + if project.config.context is project.EnvContext.android + else ( + AppiumBy.IOS_CLASS_CHAIN, + f'**/*[`label CONTAINS "{selector[5:]}"`][-1]', + ) + ) + + # BY CLASS NAME (SAME for IOS and ANDROID) + if any( + selector.lower().startswith(prefix) + for prefix in [ + 'uia', + 'xcuielementtype', + 'cyi', + 'android.widget', + 'android.view', + ] + ): + return AppiumBy.CLASS_NAME, selector + + # BY IMPLICIT ID (single word, no spaces) + if is_word_with_dashes_underscores_or_numbers(selector): + if project.config.context is project.EnvContext.android: + return to_app_package_wise_by(selector) + else: + return AppiumBy.ACCESSIBILITY_ID, selector + + # BY IMPLICIT ACCESSIBILITY ID (SAME for IOS and ANDROID) + if are_words_with_dashes_underscores_or_numbers_separated_by_space(selector): + return AppiumBy.ACCESSIBILITY_ID, selector + + raise Error(f'Unsupported selector: {selector}') diff --git a/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/path.py b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/path.py new file mode 100644 index 000000000..3d349b64e --- /dev/null +++ b/examples/run_cross_platform_android_ios/wikipedia_app_tests/support/path.py @@ -0,0 +1,10 @@ +def relative_from_root(path: str): + from examples.run_cross_platform_android_ios import wikipedia_app_tests + from pathlib import Path + + return ( + Path(wikipedia_app_tests.__file__) + .parent.parent.joinpath(path) + .absolute() + .__str__() + ) diff --git a/poetry.lock b/poetry.lock index d9adcb888..efbc310fb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,16 +2,16 @@ [[package]] name = "appium-python-client" -version = "2.11.1" +version = "4.2.0" description = "Python client for Appium" optional = false python-versions = "*" files = [ - {file = "Appium-Python-Client-2.11.1.tar.gz", hash = "sha256:0e9d3a76f03217add68a9389d5630a7b63355a6bd5ba70711c47ed3463908d33"}, + {file = "appium_python_client-4.2.0.tar.gz", hash = "sha256:ef882a54928980f2ea99822844f9cf6e7a8b93a22855abce86d8b6bf9d1a239b"}, ] [package.dependencies] -selenium = ">=4.1,<5.0" +selenium = ">=4.12,<5.0" [[package]] name = "astroid" @@ -1484,22 +1484,22 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "selenium" -version = "4.22.0" +version = "4.25.0" description = "Official Python bindings for Selenium WebDriver" optional = false python-versions = ">=3.8" files = [ - {file = "selenium-4.22.0-py3-none-any.whl", hash = "sha256:e424991196e9857e19bf04fe5c1c0a4aac076794ff5e74615b1124e729d93104"}, - {file = "selenium-4.22.0.tar.gz", hash = "sha256:903c8c9d61b3eea6fcc9809dc7d9377e04e2ac87709876542cc8f863e482c4ce"}, + {file = "selenium-4.25.0-py3-none-any.whl", hash = "sha256:3798d2d12b4a570bc5790163ba57fef10b2afee958bf1d80f2a3cf07c4141f33"}, + {file = "selenium-4.25.0.tar.gz", hash = "sha256:95d08d3b82fb353f3c474895154516604c7f0e6a9a565ae6498ef36c9bac6921"}, ] [package.dependencies] certifi = ">=2021.10.8" trio = ">=0.17,<1.0" trio-websocket = ">=0.9,<1.0" -typing_extensions = ">=4.9.0" +typing_extensions = ">=4.9,<5.0" urllib3 = {version = ">=1.26,<3", extras = ["socks"]} -websocket-client = ">=1.8.0" +websocket-client = ">=1.8,<2.0" [[package]] name = "setuptools" @@ -1783,4 +1783,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "bab2b490e72520ad77fbce7130f1a61bd06a925473f3cd6c0fea5cfa921c1141" +content-hash = "f28fa30b0cc13c555febd090c9832c8e5cfa5d193cac15dcc9be5849adb529df" diff --git a/pyproject.toml b/pyproject.toml index 8164566e4..c5f907650 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ codecov = "*" mypy = "*" pydantic = "^1.10.7" python-dotenv = "0.21.1" -Appium-Python-Client = "^2.9.0" +Appium-Python-Client = "^4.2.0" pyperclip = "^1.8.2" setuptools = "^70.0.0" diff --git a/selene/core/_context.py b/selene/core/_context.py new file mode 100644 index 000000000..bb7b62b6f --- /dev/null +++ b/selene/core/_context.py @@ -0,0 +1,108 @@ +# MIT License +# +# Copyright (c) 2015-2022 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from __future__ import annotations + +from typing import Optional, Union, Tuple + +from selenium.webdriver.remote.webdriver import WebDriver + +from selene.core._actions import _Actions +from selene.core.configuration import Config +from selene.core.entity import WaitingEntity, Element, Collection +from selene.core.locator import Locator + + +# TODO: reconsider naming it as Context, because seems like our Config - is more context than something else +# TODO: add _context.pyi +# TODO: should we make it generic on Element and Collection? +class Context(WaitingEntity['Context']): + def __init__(self, config: Optional[Config] = None): + config = Config() if config is None else config + super().__init__(config) + + def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> Context: + return ( + Context(config) + if config + else Context(self.config.with_(**config_as_kwargs)) + ) + + def __str__(self): + return 'context' + + # todo: consider not just building driver but also adjust its size according to config + @property + def driver(self) -> WebDriver: + return self.config.driver + + # TODO: consider making it callable (self.__call__() to be shortcut to self.__raw__ ...) + + @property + def __raw__(self): + return self.config.driver + + @property + def _actions(self) -> _Actions: + return _Actions(self.config) + + # --- Element builders --- # + + # TODO: consider None by default, + # and *args, **kwargs to be able to pass custom things + # to be processed by config.location_strategy + # and by default process none as "element to skip all actions on it" + def element( + self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] + ) -> Element: + if isinstance(css_or_xpath_or_by, Locator): + return Element(css_or_xpath_or_by, self.config) + + by = self.config._selector_or_by_to_by(css_or_xpath_or_by) + # todo: do we need by_to_locator_strategy? + + return Element( + Locator(f'{self}.element({by})', lambda: self.driver.find_element(*by)), + self.config, + ) + + def all( + self, css_or_xpath_or_by: Union[str, Tuple[str, str], Locator] + ) -> Collection: + if isinstance(css_or_xpath_or_by, Locator): + return Collection(css_or_xpath_or_by, self.config) + + by = self.config._selector_or_by_to_by(css_or_xpath_or_by) + + return Collection( + Locator(f'{self}.all({by})', lambda: self.driver.find_elements(*by)), + self.config, + ) + + # --- High Level Commands--- # + + # # TODO: do we need it as part of a most general search context? + # def open(self, relative_or_absolute_url: Optional[str] = None) -> Context: + # # TODO: should we keep it less pretty but more KISS? like: + # # self.config._driver_get_url_strategy(self.config)(relative_or_absolute_url) + # self.config._executor.get_url(relative_or_absolute_url) + # + # return self diff --git a/selene/core/command.py b/selene/core/command.py index cb7609621..adceab40f 100644 --- a/selene/core/command.py +++ b/selene/core/command.py @@ -289,6 +289,7 @@ def action(element: Element): from selenium.webdriver.support import expected_conditions from selenium.webdriver.support.wait import WebDriverWait +from selene.core import entity from selene.core.entity import Element, Collection from selene.core._browser import Browser from selene.core.exceptions import _SeleneError @@ -308,11 +309,11 @@ def save_screenshot(path: Optional[str] = None) -> Command[Browser]: lambda browser: browser.config._save_screenshot_strategy(browser.config, path), ) - if isinstance(path, Browser): + if entity._wraps_driver(path): # somebody passed command as `.perform(command.save_screenshot)` # not as `.perform(command.save_screenshot())` - browser = path - command.__call__(browser) + driver_wrapper = path + command.__call__(driver_wrapper) return command @@ -323,23 +324,23 @@ def save_page_source(path: Optional[str] = None) -> Command[Browser]: lambda browser: browser.config._save_page_source_strategy(browser.config, path), ) - if isinstance(path, Browser): + if entity._wraps_driver(path): # somebody passed command as `.perform(command.save_screenshot)` # not as `.perform(command.save_screenshot())` - browser = path - command.__call__(browser) + driver_wrapper = path + command.__call__(driver_wrapper) return command -def __select_all_actions(entity: Element | Browser): +def __select_all_actions(some_entity: Element | Browser): _COMMAND_KEY = Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL - actions: ActionChains = ActionChains(entity.config.driver) + actions: ActionChains = ActionChains(some_entity.config.driver) actions.key_down(_COMMAND_KEY) - if isinstance(entity, Element): - actions.send_keys_to_element(entity.locate(), 'a') + if entity._is_element(some_entity): + actions.send_keys_to_element(some_entity.locate(), 'a') # type: ignore else: actions.send_keys('a') @@ -363,7 +364,7 @@ def copy_and_paste(text: str): Does not support mobile context. Not tested with desktop apps. """ - def action(entity: Element | Browser): + def action(some_entity: Element | Browser): try: import pyperclip # type: ignore except ImportError as error: @@ -379,10 +380,10 @@ def action(entity: Element | Browser): pyperclip.copy(text) - actions = ActionChains(entity.config.driver) + actions = ActionChains(some_entity.config.driver) actions.key_down(_COMMAND_KEY) - if isinstance(entity, Element): - actions.send_keys_to_element(entity.locate(), 'v') + if entity._is_element(some_entity): + actions.send_keys_to_element(some_entity.locate(), 'v') # type: ignore else: actions.send_keys('v') actions.key_up(_COMMAND_KEY) @@ -391,13 +392,13 @@ def action(entity: Element | Browser): return Command(f'copy and paste: {text}»', action) -def __copy(entity: Element | Browser): +def __copy(some_entity: Element | Browser): _COMMAND_KEY = Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL - actions = ActionChains(entity.config.driver) + actions = ActionChains(some_entity.config.driver) actions.key_down(_COMMAND_KEY) - if isinstance(entity, Element): - actions.send_keys_to_element(entity.locate(), 'c') + if entity._is_element(some_entity): + actions.send_keys_to_element(some_entity.locate(), 'c') # type: ignore else: actions.send_keys('c') actions.key_up(_COMMAND_KEY) @@ -411,13 +412,13 @@ def __copy(entity: Element | Browser): ) -def __paste(entity: Element | Browser): +def __paste(some_entity: Element | Browser): _COMMAND_KEY = Keys.COMMAND if sys.platform == 'darwin' else Keys.CONTROL - actions = ActionChains(entity.config.driver) + actions = ActionChains(some_entity.config.driver) actions.key_down(_COMMAND_KEY) - if isinstance(entity, Element): - actions.send_keys_to_element(entity.locate(), 'v') + if entity._is_element(some_entity): + actions.send_keys_to_element(some_entity.locate(), 'v') # type: ignore else: actions.send_keys('v') actions.key_up(_COMMAND_KEY) @@ -465,7 +466,7 @@ def action(entity: Element): command = Command(f'long press with duration={duration}', action) - if isinstance(duration, Element): + if entity._is_element(duration): # somebody passed command as `.perform(command.long_press)` # not as `.perform(command.long_press())` # TODO: refactor to really allow such use case without conflicts on types diff --git a/selene/core/entity.py b/selene/core/entity.py index 2d75f7013..0731708dd 100644 --- a/selene/core/entity.py +++ b/selene/core/entity.py @@ -80,6 +80,7 @@ def config(self) -> Config: # pass +# TODO: consider renaming to simply Entity? class WaitingEntity(Matchable[E], Configured): def __init__(self, config: Config): self._config = config @@ -146,6 +147,26 @@ def matching(self, condition: Condition[E]) -> bool: return condition.predicate(typing.cast(E, self)) +def _is_element(obj: object) -> bool: + return ( + isinstance(obj, WaitingEntity) + and hasattr(obj, 'locate') + and not isinstance(obj, Iterable) + ) + + +def _is_collection(obj: object) -> bool: + return ( + isinstance(obj, WaitingEntity) + and hasattr(obj, 'locate') + and isinstance(obj, Iterable) + ) + + +def _wraps_driver(obj: object) -> bool: + return isinstance(obj, WaitingEntity) and hasattr(obj, 'driver') + + class Element(WaitingEntity['Element']): @staticmethod def _log_webelement_outer_html_for( diff --git a/selene/core/entity.pyi b/selene/core/entity.pyi index 9393c3682..e433d59e9 100644 --- a/selene/core/entity.pyi +++ b/selene/core/entity.pyi @@ -68,6 +68,10 @@ class WaitingEntity(Matchable[E], Configured, metaclass=abc.ABCMeta): def wait_until(self, condition: Condition[E]) -> bool: ... def matching(self, condition: Condition[E]) -> bool: ... +def _is_element(obj: object) -> bool: ... +def _is_collection(obj: object) -> bool: ... +def _wraps_driver(obj: object) -> bool: ... + class Element(WaitingEntity['Element']): def __init__(self, locator: Locator[WebElement], config: Config) -> None: ... def with_( diff --git a/selene/core/locator.py b/selene/core/locator.py index 589742432..dc410b024 100644 --- a/selene/core/locator.py +++ b/selene/core/locator.py @@ -19,14 +19,14 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. - +from __future__ import annotations from typing import TypeVar, Generic, Callable T = TypeVar('T') class Locator(Generic[T]): - def __init__(self, description: str, locate: Callable[[], T]): + def __init__(self, description: str | Callable[[], str], locate: Callable[[], T]): self._description = description self._locate = locate @@ -34,4 +34,4 @@ def __call__(self) -> T: return self._locate() def __str__(self): - return self._description + return self._description() if callable(self._description) else self._description diff --git a/selene/core/match.py b/selene/core/match.py index 69a1a88f9..689ab5017 100644 --- a/selene/core/match.py +++ b/selene/core/match.py @@ -50,6 +50,7 @@ from selene.common._typing_functions import Query from selene.core import query from selene.core.condition import Condition, Match +from selene.core import entity from selene.core.entity import Collection, Element, Configured from selene.core._browser import Browser @@ -811,7 +812,7 @@ def __init__( self, expected: int | dict, _name=lambda maybe_entity: ( - 'have size' if isinstance(maybe_entity, Collection) else 'has size' + 'have size' if entity._is_collection(maybe_entity) else 'has size' ), # todo: should we also tune actual rendering based on # config._match_only_visible_elements_size? @@ -827,11 +828,17 @@ def __init__( # todo: should we raise AttributeError if dict as expected is passed to Collection? super().__init__( self.__name, - actual=lambda entity: ( - len([element for element in entity.locate() if element.is_displayed()]) - if isinstance(entity, Collection) - and entity.config._match_only_visible_elements_size - else query.size(entity) + actual=lambda some_entity: ( + len( + [ + element + for element in some_entity.locate() + if element.is_displayed() + ] + ) + if entity._is_collection(some_entity) + and some_entity.config._match_only_visible_elements_size + else query.size(some_entity) ), by=_by(expected), _inverted=_inverted, @@ -869,8 +876,8 @@ def or_more(self) -> Condition[Collection]: @property def _more_than(self) -> Condition[Collection]: return Match( - lambda entity: ( - ('have' if isinstance(entity, Collection) else 'has') + lambda some_entity: ( + ('have' if entity._is_collection(some_entity) else 'has') + f' size more than {self.__expected}' ), query.size, @@ -881,8 +888,8 @@ def _more_than(self) -> Condition[Collection]: @property def _less_than(self) -> Condition[Collection]: return Match( - lambda entity: ( - ('have' if isinstance(entity, Collection) else 'has') + lambda some_entity: ( + ('have' if entity._is_collection(some_entity) else 'has') + f' size less than {self.__expected}' ), query.size, diff --git a/selene/core/query.py b/selene/core/query.py index 80bb83d04..f4421c3b3 100644 --- a/selene/core/query.py +++ b/selene/core/query.py @@ -202,6 +202,7 @@ def fn(element: Element): from selene import support from selene.common._typing_functions import Query, Command +from selene.core import entity from selene.core.entity import Element, Collection from selene.core._browser import Browser from selene.core.locator import Locator @@ -294,16 +295,19 @@ def fn(collection: Collection): # TODO: what to do now with have.size* ? o_O size: Query[Element | Collection | Browser, dict | int] = Query( 'size', - lambda entity: ( - entity.driver.get_window_size() - if isinstance(entity, Browser) + # TODO: refactor this to avoid using typing.cast or type: ignore + # by introducing isinstance based checks on some BaseClass for specific entity types + lambda some_entity: ( + some_entity.driver.get_window_size() # type: ignore + if entity._wraps_driver(some_entity) else ( - entity.locate().size - if isinstance(entity, Element) + some_entity.locate().size # type: ignore + if entity._is_element(some_entity) else ( - len(entity.locate()) - if isinstance(entity, Collection) - else typing.cast(Browser, entity).driver.get_window_size() + len(some_entity.locate()) # type: ignore + if entity._is_collection(some_entity) + # TODO: refactor this redundant else clause o_O + else typing.cast(Browser, some_entity).driver.get_window_size() ) ) ), @@ -857,11 +861,11 @@ def screenshot_saved( lambda browser: browser.config._save_screenshot_strategy(browser.config, path), ) - if isinstance(path, Browser): + if entity._wraps_driver(path): # somebody passed query as `.get(query.save_screenshot)` # not as `.get(query.save_screenshot())` - browser = path - return query.__call__(browser) # type: ignore + driver_wrapper = path + return query.__call__(driver_wrapper) # type: ignore return query @@ -874,11 +878,11 @@ def page_source_saved( lambda browser: browser.config._save_page_source_strategy(browser.config, path), ) - if isinstance(path, Browser): + if entity._wraps_driver(path): # somebody passed query as `.get(query.save_screenshot)` # not as `.get(query.page_source_saved())` - browser = path - return query.__call__(browser) # type: ignore + driver_wrapper = path + return query.__call__(driver_wrapper) # type: ignore return query diff --git a/selene/support/_mobile/__init__.py b/selene/support/_mobile/__init__.py new file mode 100644 index 000000000..d40c566d7 --- /dev/null +++ b/selene/support/_mobile/__init__.py @@ -0,0 +1,34 @@ +# MIT License +# +# Copyright (c) 2015 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from selene import _managed # noqa + + +# """ +# Just types... +# """ +from selene.support._mobile.elements import Element, AllElements # noqa +from selene.support._mobile.context import Device # noqa + +# device +from selene._managed import config + +device = Device(config) diff --git a/selene/support/_mobile/context.py b/selene/support/_mobile/context.py new file mode 100644 index 000000000..91b1011d0 --- /dev/null +++ b/selene/support/_mobile/context.py @@ -0,0 +1,206 @@ +# MIT License +# +# Copyright (c) 2015 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from __future__ import annotations + +from typing_extensions import Optional, Union, Tuple, cast + +from selene.support._mobile.elements import Element, AllElements +from selene.support._mobile.locators import ( + LOCATOR_FOR_ELEMENT_TO_SKIP, + PlatformWiseByLocator, + LOCATOR_FOR_ELEMENTS_TO_SKIP, +) + +try: + from appium import webdriver + from appium.webdriver import WebElement as AppiumElement +except ImportError as error: + raise ImportError( + 'Appium-Python-Client is not installed, ' + 'run `pip install Appium-Python-Client`,' + 'or add and install dependency ' + 'with your favorite dependency manager like poetry: ' + '`poetry add Appium-Python-Client`' + ) from error + +from selene.core._actions import _Actions +from selene.core.configuration import Config +from selene.core.entity import WaitingEntity +from selene.core.locator import Locator + + +class Device(WaitingEntity['Device']): + def __init__(self, config: Optional[Config] = None): + config = Config() if config is None else config + super().__init__(config) + + def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> Device: + return ( + Device(config) if config else Device(self.config.with_(**config_as_kwargs)) + ) + + def __str__(self): + return 'device' + + # todo: consider not just building driver but also adjust its size according to config + @property + def driver(self) -> webdriver.Remote: + return cast(webdriver.Remote, self.config.driver) + + # TODO: consider making it callable (self.__call__() to be shortcut to self.__raw__ ...) + + @property + def __raw__(self) -> webdriver.Remote: + return cast(webdriver.Remote, self.config.driver) + + @property + def _actions(self) -> _Actions: + return _Actions(self.config) + + # --- Element builders --- # + + def _is_android_or_ios(self): + return hasattr(self.config.driver_options, 'platform_name') and ( + self.config.driver_options.platform_name.lower() in ('android', 'ios') + ) + + def _value_per_current_platform( + self, + *, + same: Optional[str] = None, + or_per_platform: Optional[dict] = None, + ): + if or_per_platform is None: + or_per_platform = {} + return same or ( + or_per_platform.get('drd') + if self._is_android_or_ios() + else or_per_platform.get('web') + ) + + # TODO: consider @overload to have more specific signature variations + # TODO: consider None by default, + # and *args, **kwargs to be able to pass custom things + # to be processed by config.location_strategy + # and by default process none as "element to skip all actions on it" + def element( + self, + selector_or_by_or_locator: Union[str, Tuple[str, str], Locator, None] = None, + /, + **selector_or_by_per_platform, + ) -> Element: + if selector_or_by_or_locator is None and not selector_or_by_per_platform: + return Element(LOCATOR_FOR_ELEMENT_TO_SKIP, self.config) + + if isinstance(selector_or_by_or_locator, Locator): + if selector_or_by_per_platform: + raise ValueError( + 'You cannot pass both Locator and selector_or_by_per_platform' + ) + return Element(selector_or_by_or_locator, self.config) + + # TODO: should not we apply translation in a more lazy way, based on config? + bys_per_platform = { + 'android': self.config._selector_or_by_to_by( + selector_or_by_per_platform.get( + 'android', + selector_or_by_per_platform.get('drd', selector_or_by_or_locator), + ) + ), + 'ios': self.config._selector_or_by_to_by( + selector_or_by_per_platform.get('ios', selector_or_by_or_locator) + ), + } + + # todo: do we need by_to_locator_strategy? + return Element( + PlatformWiseByLocator( + lambda by: f'{self}.element({by})', + bys_per_platform=bys_per_platform, + config=self.config, + search=lambda by: self.driver.find_element(*by), + ), + self.config, + ) + + # return Element( + # Locator(f'{self}.element({by})', lambda: self.driver.find_element(*by)), + # self.config, + # ) + + def all( + self, + selector_or_by_or_locator: Union[str, Tuple[str, str], Locator, None] = None, + /, + **selector_or_by_per_platform, + ) -> AllElements: + # if isinstance(selector_or_by_or_locator, Locator): + # return AllElements(selector_or_by_or_locator, self.config) + # + # by = self.config._selector_or_by_to_by(selector_or_by_or_locator) + if selector_or_by_or_locator is None and not selector_or_by_per_platform: + return AllElements(LOCATOR_FOR_ELEMENTS_TO_SKIP, self.config) + + if isinstance(selector_or_by_or_locator, Locator): + if selector_or_by_per_platform: + raise ValueError( + 'You cannot pass both Locator and selector_or_by_per_platform' + ) + return AllElements(selector_or_by_or_locator, self.config) + + # TODO: should not we apply translation in a more lazy way, based on config? + bys_per_platform = { + 'android': self.config._selector_or_by_to_by( + selector_or_by_per_platform.get( + 'android', + selector_or_by_per_platform.get('drd', selector_or_by_or_locator), + ) + ), + 'ios': self.config._selector_or_by_to_by( + selector_or_by_per_platform.get('ios', selector_or_by_or_locator) + ), + } + + return AllElements( + PlatformWiseByLocator( + lambda by: f'{self}.all({by})', + bys_per_platform=bys_per_platform, + config=self.config, + search=lambda by: self.driver.find_elements(*by), + ), + self.config, + ) + + # return AllElements( + # Locator(f'{self}.all({by})', lambda: self.driver.find_elements(*by)), + # self.config, + # ) + + # --- High Level Commands--- # + + # # TODO: do we need it as part of a most general search context? + # def open(self, relative_or_absolute_url: Optional[str] = None) -> Device: + # # TODO: should we keep it less pretty but more KISS? like: + # # self.config._driver_get_url_strategy(self.config)(relative_or_absolute_url) + # self.config._executor.get_url(relative_or_absolute_url) + # + # return self diff --git a/selene/support/_mobile/elements.py b/selene/support/_mobile/elements.py new file mode 100644 index 000000000..d81b79045 --- /dev/null +++ b/selene/support/_mobile/elements.py @@ -0,0 +1,765 @@ +# MIT License +# +# Copyright (c) 2015 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +from selenium.webdriver import ActionChains +from typing_extensions import Optional, Union, Tuple, cast, Iterable, Sequence, Callable + +from selene.common._typing_functions import Command +from selene.common.helpers import flatten +from selene.core.condition import Condition +from selene.core.configuration import Config +from selene.core.entity import WaitingEntity +from selene.core.locator import Locator +from selene.core.wait import Wait + +try: + from appium import webdriver + from appium.webdriver import WebElement as AppiumElement +except ImportError as error: + raise ImportError( + 'Appium-Python-Client is not installed, ' + 'run `pip install Appium-Python-Client`,' + 'or add and install dependency ' + 'with your favorite dependency manager like poetry: ' + '`poetry add Appium-Python-Client`' + ) from error + + +class Element(WaitingEntity['Element']): + # TODO: should we move locator based init and with_ to Located base abstract class? + + # TODO: we need a separate Config for Mobile + # e.g. we don't need log_outer_html_on_failure for mobile, etc. + def __init__(self, locator: Locator[AppiumElement], config: Config): + self._locator = locator + super().__init__(config) + + # --- Configured --- # + + def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> Element: + return Element( + self._locator, + config if config else self.config.with_(**config_as_kwargs), + ) + + # --- Located --- # + + def __str__(self): + return str(self._locator) + + def locate(self) -> AppiumElement: + return self._locator() + + @property + def __raw__(self): + return self.locate() + + def __call__(self) -> AppiumElement: + return self.locate() + + # --- WaitingEntity --- # + + @property + def wait(self) -> Wait[Element]: + # TODO: will not it break code like browser.with_(timeout=...)? + # TODO: fix that will disable/break shared hooks (snapshots) + # return Wait(self, # TODO: isn't it slower to create it each time from scratch? move to __init__? + # at_most=self.config.timeout, + # or_fail_with=pipe( + # Element._log_webelement_outer_html_for(self), + # self.config.hook_wait_failure)) + return super().wait + + # @property + # def cached(self) -> Element: + # # TODO: do we need caching ? with lazy save of webelement to cache + # + # cache = None + # error = None + # try: + # cache = self.locate() + # except Exception as e: + # error = e + # + # def get_webelement(): + # if cache: + # return cache + # raise error + # + # return Element(Locator(f'{self}.cached', get_webelement), self.config) + + # --- Relative location --- # + + # TODO: refactor for platform wise locators + def element(self, selector_or_by: Union[str, Tuple[str, str]], /) -> Element: + by = self.config._selector_or_by_to_by(selector_or_by) + + return Element( + Locator(f'{self}.element({by})', lambda: self.locate().find_element(*by)), + self.config, + ) + + def all(self, selector_or_by: Union[str, Tuple[str, str]], /) -> AllElements: + by = self.config._selector_or_by_to_by(selector_or_by) + + return AllElements( + Locator(f'{self}.all({by})', lambda: self.locate().find_elements(*by)), + self.config, + ) + + # --- Commands --- # + + # TODO: can we implement script on SELF for Mobile? + # def execute_script(self, script_on_self: str, *arguments): + # """ + # Executes JS script on self as webelement. Will not work for Mobile! + # + # The script can use predefined parameters: + # - ``element`` and ``self`` are aliases to this element handle, i.e. ``self.locate()`` or ``self.locate()``. + # - ``arguments`` are accessible from the script with same order and indexing as they are provided to the method + # + # Examples:: + # + # browser.element('[id^=google_ads]').execute_script('element.remove()') + # # OR + # browser.element('[id^=google_ads]').execute_script('self.remove()') + # ''' + # # are shortcuts to + # browser.execute_script('arguments[0].remove()', browser.element('[id^=google_ads]')()) + # ''' + # + # browser.element('input').execute_script('element.value=arguments[0]', 'new value') + # # OR + # browser.element('input').execute_script('self.value=arguments[0]', 'new value') + # ''' + # # are shortcuts to + # browser.execute_script('arguments[0].value=arguments[1]', browser.element('input').locate(), 'new value') + # ''' + # """ + # driver = cast(webdriver.Remote, self.config.driver) + # webelement = self() + # # TODO: should we wrap it in wait or not? + # # TODO: should we add additional it and/or its aliases for element? + # return driver.execute_driver( + # script_on_self, + # # webelement, + # # arguments, + # ) + + def set_value(self, value: Union[str, int]) -> Element: + # TODO: should we move all commands like following or queries like in conditions - to separate py modules? + # todo: should we make them webelement based (Callable[[MobileElement], None]) instead of element based? + def fn(element: Element): + mobelement = element.locate() + mobelement.clear() + mobelement.send_keys(str(value)) + + self.wait.for_(Command(f'set value: {value}', fn)) + + # todo: consider returning self.cached, since after first successful call, + # all next ones should normally pass + # no waiting will be needed normally + # if yes - then we should pass fn commands to wait.for_ + # so the latter will return webelement to cache + # also it will make sense to make this behaviour configurable... + return self + + def set(self, value: Union[str, int]) -> Element: + """ + Sounds similar to Element.set_value(self, value), but considered to be used in broader context. + For example, a use can say «Set gender radio to Male» but will hardly say «Set gender radio to value Male». + Now imagine, on your project you have custom html implementation of radio buttons, + and want to teach selene to set such radio-button controls + – you can do this via Element.set(self, value) method, + after monkey-patching it according to your behavior;) + """ + return self.set_value(value) + + def type(self, text: Union[str, int]) -> Element: + def fn(element: Element): + mobelement = element.locate() + mobelement.send_keys(str(text)) + + self.wait.for_(Command(f'type: {text}', fn)) + + return self + + def send_keys(self, *value) -> Element: + """ + Similar to type(text), but with a more low-level naming & args, + that might be useful in some cases. + """ + # todo: here it's a bit weird... we actually needed command not query, + # but because of send_keys in Appium returns not None, we have to use query + # to ensure correct typing + self.wait.query('send keys', lambda element: element.locate().send_keys(*value)) + return self + + def press(self, *keys) -> Element: + """ + Similar to send_keys but with a more high level naming + """ + + def fn(element: Element): + mobelement = element.locate() + mobelement.send_keys(*keys) + + self.wait.command(f'press keys: {keys}', fn) + + return self + + def clear(self) -> Element: + def fn(element: Element): + mobelement = element.locate() + mobelement.clear() + + self.wait.command('clear', fn) + + return self + + # TODO: consider support of percentage in offsets (in command.js.click too) + # TODO: will this implementation of offsets work for Mobile? + # TODO: should TAPPING be implemented in different than simple clicking way? + # TODO: do we need a click alias? should render the name of it differently per platform? + def tap(self, *, xoffset=0, yoffset=0) -> Element: + """Just a normal tap action with optional offset:)""" + + def raw_click(element: Element): + element.locate().click() + + def click_with_offset_actions(element: Element): + actions: ActionChains = ActionChains(self.config.driver) + mobelement = element.locate() + actions.move_to_element_with_offset( + mobelement, xoffset, yoffset + ).click().perform() + + self.wait.for_( + ( + Command('tap', raw_click) + if (not xoffset and not yoffset) + else Command( + f'tap(xoffset={xoffset},yoffset={yoffset})', + click_with_offset_actions, + ) + ) + ) + + return self + + def long_press(self, duration=1.0) -> Element: + """Long press on element (also known as “touch and hold”) with duration in milliseconds. + + Args: + duration (float): duration of the hold between press and release in seconds + """ + from selene.core import command + + self.perform(command.long_press(duration)) + return self + + +# todo: wouldn't it be enough to name it as All? (currently we have All as alias to AllElements) +class AllElements(WaitingEntity['AllElements'], Iterable[Element]): + def __init__(self, locator: Locator[Sequence[AppiumElement]], config: Config): + self._locator = locator + super().__init__(config) + + def with_(self, config: Optional[Config] = None, **config_as_kwargs) -> AllElements: + return AllElements( + self._locator, + config if config else self.config.with_(**config_as_kwargs), + ) + + def __str__(self): + return str(self._locator) + + def locate(self) -> Sequence[AppiumElement]: + return self._locator() + + @property + def __raw__(self): + return self.locate() + + def __call__(self) -> Sequence[AppiumElement]: + return self.locate() + + @property + def cached(self) -> AllElements: + mobelements = self.locate() + return AllElements(Locator(f'{self}.cached', lambda: mobelements), self.config) + + def __iter__(self): + i = 0 + cached = self.cached + while i < len(cached()): + element = cached[i] + yield element + i += 1 + + def __len__(self): + from selene.core import query + + return self.get(query.size) + + # TODO: add config.index_collection_from_1, disabled by default + # TODO: consider additional number param, that counts from 1 + # if provided instead of index + def element(self, index: int, /) -> Element: + def find() -> AppiumElement: + mobelements = self.locate() + length = len(mobelements) + + if length <= index: + raise AssertionError( + f'Cannot get element with index {index} ' + + f'from mobile elements collection with length {length}' + ) + + return mobelements[index] + + return Element(Locator(f'{self}[{index}]', find), self.config) + + @property + def first(self) -> Element: + """ + A human-readable alias to .element(0) or [0] + """ + return cast(Element, self[0]) + + @property + def second(self) -> Element: + """ + A human-readable alias to .element(1) or [1] + """ + return cast(Element, self[1]) + + @property + def even(self): + """ + A human-readable alias to [1::2], i.e. filtering collection to have only even elements + """ + return self[1::2] + + @property + def odd(self): + """ + A human-readable alias to [::2], i.e. filtering collection to have only odd elements + """ + return self[::2] + + def sliced( + self, + start: Optional[int] = None, + stop: Optional[int] = None, + step: int = 1, + ) -> AllElements: + def find() -> Sequence[AppiumElement]: + mobelements = self.locate() + length = len(mobelements) + if start is not None and start != 0 and start >= length: + raise AssertionError( + f'not enough elements to slice collection ' + f'from START on index={start}, ' + f'actual elements collection length is {length}' + ) + if stop is not None and stop != -1 and length < stop: + raise AssertionError( + 'not enough elements to slice collection ' + f'from {start or "START"} to STOP at index={stop}, ' + f'actual elements collection length is {length}' + ) + + # TODO: assert length according to provided start, stop... + + return mobelements[start:stop:step] + + return AllElements( + Locator( + f'{self}[{start or ""}' + f':{stop or ""}' + f'{":" + str(step) if step else ""}]', + find, + ), + self.config, + ) + + def __getitem__( + self, index_or_slice: Union[int, slice] + ) -> Union[Element, AllElements]: + if isinstance(index_or_slice, slice): + return self.sliced( + index_or_slice.start, index_or_slice.stop, index_or_slice.step + ) + + return self.element(index_or_slice) + + def from_(self, start: int) -> AllElements: + return cast(AllElements, self[start:]) + + def to(self, stop: int) -> AllElements: + return cast(AllElements, self[:stop]) + + def by( + self, condition: Union[Condition[Element], Callable[[Element], None]] + ) -> AllElements: + condition = ( + condition + if isinstance(condition, Condition) + else Condition(str(condition), condition) # TODO: check here for fn name + ) + + return AllElements( + Locator( + f'{self}.by({condition})', + lambda: [ + element.locate() + for element in self.cached + if element.matching(condition) + ], + ), + self.config, + ) + + def by_their( + self, + selector: Union[str, Tuple[str, str], Callable[[Element], Element]], + condition: Condition[Element], + ) -> AllElements: + """ + Returns elements from collection that have inner/relative element, + found by ``selector`` and matching ``condition``. + + Is a shortcut for ``collection.by(lambda element: condition(element.element(selector))``. + + Example (straightforward) + ------------------------- + + GIVEN html elements somewhere in DOM:: + .result + .result-title + .result-url + .result-snippet + + THEN:: + + browser.all('.result')\ + .by_their('.result-title', have.text('Selene'))\ + .should(have.size(3)) + + is similar to:: + + browser.all('.result')\ + .by_their(lambda it: have.text(text)(it.element('.result-title')))\ + .should(have.size(3)) + + Example (PageObject) + -------------------- + + GIVEN html elements somewhere in DOM:: + .result + .result-title + .result-url + .result-snippet + + AND:: + + results = browser.all('.result') + class Result: + def __init__(self, element): + self.element = element + self.title = self.element.element('.result-title') + self.url = self.element.element('.result-url') + # ... + + THEN:: + + results.by_their(lambda it: Result(it).title, have.text(text))\ + .should(have.size(3)) + + is similar to:: + + results.by_their(lambda it: have.text(text)(Result(it).title))\ + .should(have.size(3)) + """ + + def find_in(parent: Element) -> Element: + if callable(selector): + return selector(parent) + else: + return parent.element(selector) + + return self.by(lambda it: condition(find_in(it))) + + def element_by( + self, condition: Union[Condition[Element], Callable[[Element], None]] + ) -> Element: + # TODO: a first_by(condition) alias would be shorter, + # and more consistent with by(condition).first + # but the phrase items.element_by(have.text('foo')) leads to a more + # natural meaning that such element should be only one... + # while items.first_by(have.text('foo')) gives a clue that + # it's just one of many... + # should we then make element_by fail + # if the condition matches more than one element? (maybe we can control it via corresponding config option?) + # yet we don't fail if browser.element(selector) or element.element(selector) + # finds more than one element... o_O + + # TODO: In the implementation below... + # We use condition in context of "matching", i.e. as a predicate... + # why then not accept Callable[[E], bool] also? + # (as you remember, Condition is Callable[[E], None] throwing Error) + # This will allow the following code be possible + # results.element_by(lambda it: + # Result(it).title.matching(have.text(text))) + # instead of: + # results.element_by(lambda it: have.text(text)( + # Result(it).title)) + # in addition to: + # results.element_by_its(lambda it: + # Result(it).title, have.text(text)) + # Open Points: + # - do we need element_by_its, if we allow Callable[[E], bool] ? + # - if we add elements_by_its, do we need then to accept Callable[[E], bool] ? + # - probably... Callable[[E], bool] will lead to worse error messages, + # in such case we ignore thrown error's message + # - hm... ut seems like we nevertheless ignore it... + # we use element.matching(condition) below + condition = ( + condition + if isinstance(condition, Condition) + else Condition(str(condition), condition) + ) + + def find() -> AppiumElement: + cached = self.cached + + for element in cached: + if element.matching(condition): + return element.locate() + + from selene.core import query + + if self.config.log_outer_html_on_failure: + """ + TODO: move it support.shared.config + """ + outer_htmls = [query.outer_html(element) for element in cached] + + raise AssertionError( + f'\n\tCannot find element by condition «{condition}» ' + f'\n\tAmong {self}' + f'\n\tActual mobelements collection:' + f'\n\t{outer_htmls}' + ) # TODO: isn't it better to print it all the time via hook, like for Element? + else: + raise AssertionError( + f'\n\tCannot find element by condition «{condition}» ' + f'\n\tAmong {self}' + ) + + return Element(Locator(f'{self}.element_by({condition})', find), self.config) + + def element_by_its( + self, + selector: Union[str, Tuple[str, str], Callable[[Element], Element]], + condition: Condition[Element], + ) -> Element: + """ + Returns element from collection that has inner/relative element + found by ``selector`` and matching ``condition``. + Is a shortcut for ``collection.element_by(lambda its: condition(its.element(selector))``. + + Example (straightforward) + ------------------------- + + GIVEN html elements somewhere in DOM:: + + .result + .result-title + .result-url + .result-snippet + + THEN:: + + browser.all('.result')\ + .element_by_its('.result-title', have.text(text))\ + .element('.result-url').click() + + ... is a shortcut for:: + + browser.all('.result')\ + .element_by(lambda its: have.text(text)(its.element('.result-title')))\ + .element('.result-url').click() + + Example (PageObject) + -------------------- + + GIVEN html elements somewhere in DOM:: + + .result + .result-title + .result-url + .result-snippet + + AND:: + + results = browser.all('.result') + class Result: + def __init__(self, element): + self.element = element + self.title = self.element.element('.result-title') + self.url = self.element.element('.result-url') + + THEN:: + + Result(results.element_by_its(lambda it: Result(it).title, have.text(text)))\ + .url.click() + + is a shortcut for:: + + Result(results.element_by(lambda it: have.text(text)(Result(it).title)))\ + .url.click() + # ... + """ + + # TODO: tune implementation to ensure error messages are ok + + def find_in(parent: Element): + if callable(selector): + return selector(parent) + else: + return parent.element(selector) + + return self.element_by(lambda it: condition(find_in(it))) + + def collected( + self, finder: Callable[[Element], Union[Element, AllElements]] + ) -> AllElements: + # TODO: consider adding predefined queries to be able to write + # collected(query.element(selector)) + # over + # collected(lambda element: element.element(selector)) + # and + # collected(query.all(selector)) + # over + # collected(lambda element: element.all(selector)) + # consider also putting such element builders like to find.* module instead of query.* module + # because they are not supposed to be used in entity.get(*) context defined for other query.* fns + + return AllElements( + Locator( + f'{self}.collected({finder})', + # TODO: consider skipping None while flattening + lambda: cast( + Sequence[AppiumElement], + flatten([finder(element)() for element in self.cached]), + ), + ), + self.config, + ) + + def all(self, selector: Union[str, Tuple[str, str]]) -> AllElements: + """ + Returns a collection of all elements found be selector inside each element of self + + An alias to ``collection.collected(lambda its: its.all(selector))``. + + Example + ------- + + Given html:: + + + + + + + + +
A1A2
B1B2
+ + Then:: + + browser.all('.row').all('.cell')).should(have.texts('A1', 'A2', 'B1', 'B2')) + """ + by = self.config._selector_or_by_to_by(selector) + + # TODO: consider implement it through calling self.collected + # because actually the impl is self.collected(lambda element: element.all(selector)) + + return AllElements( + Locator( + f'{self}.all({by})', + lambda: cast( + Sequence[AppiumElement], + flatten( + [mobelement.find_elements(*by) for mobelement in self.locate()] + ), + ), + ), + self.config, + ) + + # todo: consider collection.all_first(number, selector) to get e.g. two first td from each tr + def all_first(self, selector: Union[str, Tuple[str, str]]) -> AllElements: + """ + Returns a collection of each first element found be selector inside each element of self + + An alias to ``collection.collected(lambda its: its.element(selector))``. + Not same as ``collection.all(selector).first`` that is same as ``collection.first.element(selector)`` + + Example + ------- + + Given html:: + + + + + + + + +
A1A2
B1B2
+ + Then:: + + browser.all('.row').all_first('.cell')).should(have.texts('A1', 'B1')) + """ + by = self.config._selector_or_by_to_by(selector) + + # TODO: consider implement it through calling self.collected + # because actually the impl is self.collected(lambda element: element.element(selector)) + + return AllElements( + Locator( + f'{self}.all_first({by})', + lambda: [mobelement.find_element(*by) for mobelement in self.locate()], + ), + self.config, + ) + + +All = AllElements diff --git a/selene/support/_mobile/locators.py b/selene/support/_mobile/locators.py new file mode 100644 index 000000000..40dd97875 --- /dev/null +++ b/selene/support/_mobile/locators.py @@ -0,0 +1,116 @@ +# MIT License +# +# Copyright (c) 2015 Iakiv Kramarenko +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +from __future__ import annotations + +from typing_extensions import ( + cast, + TypeVar, + Callable, + override, + Optional, + Sequence, + Tuple, +) + +from selene.core.configuration import Config +from selene.core.locator import Locator + +try: + from appium import webdriver + from appium.webdriver import WebElement as AppiumElement +except ImportError as error: + raise ImportError( + 'Appium-Python-Client is not installed, ' + 'run `pip install Appium-Python-Client`,' + 'or add and install dependency ' + 'with your favorite dependency manager like poetry: ' + '`poetry add Appium-Python-Client`' + ) from error + + +class _SkippedAppiumElement: + """Element that ignores all actions, returning None on any call""" + + def __getattr__(self, item): + return lambda *args, **kwargs: None + + +class _SkippedAppiumElements: + """Element that ignores all actions, returning None on any call""" + + def __getattr__(self, item): + return lambda *args, **kwargs: None + + +LOCATOR_FOR_ELEMENT_TO_SKIP = Locator( + 'Element that ignores all actions', + lambda: cast(AppiumElement, _SkippedAppiumElement()), +) + + +LOCATOR_FOR_ELEMENTS_TO_SKIP = Locator( + 'Element that ignores all actions', + lambda: cast(Sequence[AppiumElement], _SkippedAppiumElements()), +) + + +T = TypeVar('T') + + +class NoneWiseLocator(Locator[T]): + # def __init__(self, description: str, locate: Callable[[], T]): + # self._description = description + # self._locate = locate + + @override + def __call__(self) -> T: + located = self._locate() + return located if located is not None else cast(T, _SkippedAppiumElement()) + + # def __str__(self): + # return self._description + + +class PlatformWiseByLocator(Locator[T]): + def __init__( + self, + description: Callable[[Tuple[str, str]], str], + *, + search: Callable, + bys_per_platform, + config: Config, + ): + self._config = config + self._bys_per_platform = bys_per_platform + + def locate(): + by = bys_per_platform.get(self._current_platform_name) + return search(by) if by is not None else cast(T, _SkippedAppiumElement()) + + super().__init__( + lambda: description(bys_per_platform.get(self._current_platform_name)), + locate, + ) + + @property + def _current_platform_name(self): + return self._config.driver.capabilities.get('platformName', '').lower()