From c2094560996b02e3d722cbd6d3b2356443608035 Mon Sep 17 00:00:00 2001 From: "dmy.berezovskyi" Date: Thu, 13 Mar 2025 08:54:15 +0200 Subject: [PATCH 1/4] added: ability to scroll to element and wait till element is visible - Updated `scroll_until_element_visible` to first find the elements using locators before passing them to the `scroll` function. - Enhanced scrolling logic to ensure elements are properly located and scrolled into view. chore: updated ruff configuration to exclude docstring checks - Modified `.ruff.toml` to exclude docstring-related linting rules. --- .ruff.toml | 3 ++ src/locators/locators.py | 3 ++ src/screens/base_screen.py | 39 ++++++++++++---- src/screens/element_interactor.py | 64 ++++++++++++++++++-------- src/screens/main_screen/main_screen.py | 42 ++++++++++------- tests/test_p1/test_actions.py | 16 +++++-- 6 files changed, 118 insertions(+), 49 deletions(-) diff --git a/.ruff.toml b/.ruff.toml index 029be50..a515c23 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -24,6 +24,9 @@ ignore = [ "N801", # Function name should be lowercase "I001", # Import convention violation "F631", # Assert should not be used with a literal + "D212", # Multi-line docstring should start on the first line + "D213", # Multi-line docstring should start on the second line + "E501", # Line too long ] # Regular expression for dummy variables diff --git a/src/locators/locators.py b/src/locators/locators.py index fec3cdd..90da45e 100644 --- a/src/locators/locators.py +++ b/src/locators/locators.py @@ -10,6 +10,9 @@ class main_menu: class views_menu: TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, 'TextFields') + ANIMATION_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Animation') + GALLERY_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Gallery') + IMAGE_BUTTON = (AppiumBy.ACCESSIBILITY_ID, 'ImageButton') class views_fields: HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, 'hint') \ No newline at end of file diff --git a/src/screens/base_screen.py b/src/screens/base_screen.py index 6fac3a1..4dcc5c7 100644 --- a/src/screens/base_screen.py +++ b/src/screens/base_screen.py @@ -1,13 +1,7 @@ import time -from typing import Tuple, Literal, Optional - -from selenium.webdriver.common.actions import interaction -from selenium.webdriver.common.actions.action_builder import ActionBuilder -from selenium.webdriver.common.actions.pointer_actions import PointerActions -from selenium.webdriver.common.actions.pointer_input import PointerInput +from typing import Tuple, Literal from screens.element_interactor import ElementInteractor -from appium.webdriver.extensions.action_helpers import ActionHelpers, ActionChains Locator = Tuple[str, str] @@ -27,7 +21,7 @@ def click( def tap(self, locator: Locator, duration: float = 500, **kwargs): """Taps on an element using ActionHelpers. - Taps on an particular place with up to five fingers, holding for a + Taps on a particular place with up to five fingers, holding for a certain duration :param locator: locator of an element @@ -72,7 +66,7 @@ def scroll( end_ratio: float = 0.3, ): """ - Scrolls down the screen with customizable scroll size. + Scrolls down/up the screen with customizable scroll size. :param directions: up or down: :param start_ratio: Percentage (0-1) from where the scroll starts @@ -100,6 +94,33 @@ def scroll( self.scroll_by_coordinates(start_x, start_y, start_x, end_y) + def scroll_to_element( + self, from_el: Locator, destination_el: Locator, duration: [int] = 500 + ): + """Scrolls to the destination element(Both elements must be located(visible)). + + :param from_el: Locator of the element to start scrolling from. + :param destination_el: Locator of the target element to scroll to. + :param duration: Optional duration for each scroll. + """ + from_element = self.element(from_el) + to_element = self.element(destination_el) + + self.driver.scroll(to_element, from_element, duration=duration) + + def scroll_until_element_visible( + self, + destination_el: Locator, + directions: Literal["down", "up"] = "down", + start_ratio: float = 0.6, + end_ratio: float = 0.3, + retries: int = 1, + ): + while self.is_exist(destination_el, expected=False, n=retries): + self.scroll( + directions=directions, start_ratio=start_ratio, end_ratio=end_ratio + ) + def type(self, locator: Locator, text: str): element = self.element(locator) element.send_keys(text) diff --git a/src/screens/element_interactor.py b/src/screens/element_interactor.py index ecc2815..bc739ab 100644 --- a/src/screens/element_interactor.py +++ b/src/screens/element_interactor.py @@ -126,29 +126,54 @@ def is_exist( expected: bool = True, n: int = 3, condition: Literal["clickable", "visible", "present"] = "visible", - wait_type: Optional[WaitType] = WaitType.DEFAULT, + wait_type: Optional[WaitType] = WaitType.SHORT, + retry_delay: float = 0.5, ) -> bool: + """ + Checks if an element exists on the screen within a specified number of retries. + + :param retry_delay: delay between retry + :param locator: The locator tuple (strategy, value) used to find the element. + :param expected: Determines whether the element should exist (True) or not (False). + :param n: The number of attempts to check for the element before returning a result. + :param condition: The condition to check for the element's existence. + - "clickable": Ensures the element is interactable. + - "visible": Ensures the element is visible on the page. + - "present": Ensures the element exists in the DOM (even if not visible). + :param wait_type: Specifies the wait strategy (default is WaitType.DEFAULT). + :return: True if the element matches the expected state, False otherwise. + :rtype: bool + + + **Usage Example:** + + screen.is_exist(("id", "login-button")) + True + + screen.is_exist(("id", "error-popup"), expected=False) + True + """ for _ in range(n): try: element = self.element( locator, n=1, condition=condition, wait_type=wait_type ) return element.is_displayed() == expected - except NoSuchElementException: + except (NoSuchElementException, TimeoutException): if not expected: return True - except Exception: - pass - time.sleep(0.5) + except Exception as e: + print(f"Unexpected error in is_exist: {e}") + time.sleep(retry_delay) return not expected - + def scroll_by_coordinates( - self, - start_x: int, - start_y: int, - end_x: int, - end_y: int, - duration: Optional[int] = None, + self, + start_x: int, + start_y: int, + end_x: int, + end_y: int, + duration: Optional[int] = None, ): """Scrolls from one set of coordinates to another. @@ -161,17 +186,18 @@ def scroll_by_coordinates( """ if duration is None: duration = 700 - + touch_input = PointerInput(interaction.POINTER_TOUCH, "touch") actions = ActionChains(self.driver) - - actions.w3c_actions = ActionBuilder(self.driver, mouse = touch_input) + + actions.w3c_actions = ActionBuilder(self.driver, mouse=touch_input) actions.w3c_actions.pointer_action.move_to_location(start_x, start_y) actions.w3c_actions.pointer_action.pointer_down() - actions.w3c_actions = ActionBuilder(self.driver, mouse=touch_input, duration=duration) - + actions.w3c_actions = ActionBuilder( + self.driver, mouse=touch_input, duration=duration + ) + actions.w3c_actions.pointer_action.move_to_location(end_x, end_y) actions.w3c_actions.pointer_action.release() - + actions.perform() - diff --git a/src/screens/main_screen/main_screen.py b/src/screens/main_screen/main_screen.py index 5de4a34..61ab574 100644 --- a/src/screens/main_screen/main_screen.py +++ b/src/screens/main_screen/main_screen.py @@ -5,19 +5,29 @@ class MainScreen(Screen): - - def __init__(self, driver): - super().__init__(driver) - self.locators = Locators() - - def click_on_text_link(self): - self.click(locator = self.locators.main_menu.TEXT_LINK) - - def tap_on_text_link(self): - self.tap(locator = self.locators.main_menu.TEXT_LINK) - - def scroll_view_by_coordinates(self, direction: Literal['down', 'up'] = 'down'): - self.tap(locator = self.locators.main_menu.VIEWS_LINK) - self.scroll(directions = direction) - - \ No newline at end of file + def __init__(self, driver): + super().__init__(driver) + self.locators = Locators() + + def click_on_text_link(self): + self.click(locator=self.locators.main_menu.TEXT_LINK) + + def tap_on_text_link(self): + self.tap(locator=self.locators.main_menu.TEXT_LINK) + + def scroll_view_by_coordinates(self, direction: Literal["down", "up"] = "down"): + self.tap(locator=self.locators.main_menu.VIEWS_LINK) + self.scroll(directions=direction) + + def scroll_to_image_button(self): + self.tap(locator=self.locators.main_menu.VIEWS_LINK) + self.scroll_to_element( + from_el=self.locators.views_menu.ANIMATION_LINK, + destination_el=self.locators.views_menu.IMAGE_BUTTON, + ) + + def scroll_until_text_field_visible(self): + self.tap(locator=self.locators.main_menu.VIEWS_LINK) + self.scroll_until_element_visible( + destination_el=self.locators.views_menu.TEXT_FIELDS + ) diff --git a/tests/test_p1/test_actions.py b/tests/test_p1/test_actions.py index 5b1864e..633691c 100644 --- a/tests/test_p1/test_actions.py +++ b/tests/test_p1/test_actions.py @@ -7,13 +7,19 @@ class TestClick: def setup(self, driver) -> None: """Setup common objects for tests after address is set.""" self.main_screen = MainScreen(driver) - + def test_click(self, setup): self.main_screen.click_on_text_link() - + def test_tap(self, setup): self.main_screen.tap_on_text_link() - + def test_scroll_by_coordinates(self, setup): - self.main_screen.scroll_view_by_coordinates(direction = "down") - self.main_screen.scroll('up') \ No newline at end of file + self.main_screen.scroll_view_by_coordinates(direction="down") + self.main_screen.scroll("up") + + def test_sroll_to_element(self, setup): + self.main_screen.scroll_to_image_button() + + def test_scroll_util_visible(self, setup): + self.main_screen.scroll_until_text_field_visible() From 8139d9953ccf467afab4108d54a53c50aa69425d Mon Sep 17 00:00:00 2001 From: "dmy.berezovskyi" Date: Thu, 13 Mar 2025 08:56:29 +0200 Subject: [PATCH 2/4] fix ruff --- src/locators/locators.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/locators/locators.py b/src/locators/locators.py index 90da45e..8f1c63f 100644 --- a/src/locators/locators.py +++ b/src/locators/locators.py @@ -2,17 +2,17 @@ class Locators: - class main_menu: - TEXT_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Text') - CONTENT_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Content') - VIEWS_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Views') - MENU_ELEMENTS = (AppiumBy.XPATH, '//android.widget.TextView') - - class views_menu: - TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, 'TextFields') - ANIMATION_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Animation') - GALLERY_LINK = (AppiumBy.ACCESSIBILITY_ID, 'Gallery') - IMAGE_BUTTON = (AppiumBy.ACCESSIBILITY_ID, 'ImageButton') - - class views_fields: - HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, 'hint') \ No newline at end of file + class main_menu: + TEXT_LINK = (AppiumBy.ACCESSIBILITY_ID, "Text") + CONTENT_LINK = (AppiumBy.ACCESSIBILITY_ID, "Content") + VIEWS_LINK = (AppiumBy.ACCESSIBILITY_ID, "Views") + MENU_ELEMENTS = (AppiumBy.XPATH, "//android.widget.TextView") + + class views_menu: + TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, "TextFields") + ANIMATION_LINK = (AppiumBy.ACCESSIBILITY_ID, "Animation") + GALLERY_LINK = (AppiumBy.ACCESSIBILITY_ID, "Gallery") + IMAGE_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "ImageButton") + + class views_fields: + HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, "hint") From 6adba2d20361ef88f0ffc7ffb872e4a57a87d50b Mon Sep 17 00:00:00 2001 From: "dmy.berezovskyi" Date: Thu, 13 Mar 2025 09:14:01 +0200 Subject: [PATCH 3/4] fix qodana --- src/screens/element_interactor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/screens/element_interactor.py b/src/screens/element_interactor.py index bc739ab..37bf223 100644 --- a/src/screens/element_interactor.py +++ b/src/screens/element_interactor.py @@ -12,6 +12,7 @@ from selenium.common.exceptions import TimeoutException, NoSuchElementException Locator = Tuple[str, str] +type Condition = Literal["clickable", "visible", "present"] class WaitType(Enum): @@ -32,14 +33,15 @@ def __init__(self, driver): self.waiters[WaitType.FLUENT] = WebDriverWait( driver, WaitType.FLUENT.value, poll_frequency=1 ) - + def _get_waiter(self, wait_type: Optional[WaitType] = None) -> WebDriverWait: + """Returns the appropriate waiter based on the given wait_type.""" return self.waiters.get(wait_type, self.waiters[WaitType.DEFAULT]) def wait_for( self, locator: Locator, - condition: Literal["clickable", "visible", "present"] = "visible", + condition: Condition = "visible", waiter: Optional[WebDriverWait] = None, ) -> WebElement: waiter = waiter or self._get_waiter() @@ -61,7 +63,7 @@ def element( self, locator: Locator, n: int = 3, - condition: Literal["clickable", "visible", "present"] = "visible", + condition: Condition = "visible", wait_type: Optional[WaitType] = WaitType.DEFAULT, ): for attempt in range(1, n + 1): @@ -80,7 +82,7 @@ def elements( self, locator: Locator, n: int = 3, - condition: Literal["clickable", "visible", "present"] = "visible", + condition: Condition = "visible", wait_type: Optional[WaitType] = WaitType.DEFAULT, ) -> List[WebElement]: for attempt in range(1, n + 1): @@ -100,7 +102,7 @@ def is_displayed( locator: Locator, expected: bool = True, n: int = 3, - condition: Literal["clickable", "visible", "present"] = "visible", + condition: Condition = "visible", wait_type: Optional[WaitType] = None, ) -> None: wait_type = wait_type or WaitType.DEFAULT @@ -125,7 +127,7 @@ def is_exist( locator: Locator, expected: bool = True, n: int = 3, - condition: Literal["clickable", "visible", "present"] = "visible", + condition: Condition = "visible", wait_type: Optional[WaitType] = WaitType.SHORT, retry_delay: float = 0.5, ) -> bool: From 08c3eba45d832a58792c471db3d3c3b4a3ea7b32 Mon Sep 17 00:00:00 2001 From: "dmy.berezovskyi" Date: Thu, 13 Mar 2025 09:33:09 +0200 Subject: [PATCH 4/4] fix qodana --- conftest.py | 6 +++--- src/screens/base_screen.py | 17 ++++++----------- src/screens/element_interactor.py | 10 +++++----- tests/test_p1/test_actions.py | 2 +- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/conftest.py b/conftest.py index c38b5ff..d7d73c2 100644 --- a/conftest.py +++ b/conftest.py @@ -62,10 +62,10 @@ def driver(request): except Exception as e: pytest.fail(f"Failed to initialize driver: {e}") - yield driver + yield event_driver - if driver is not None: - driver.quit() + if event_driver is not None: + event_driver.quit() # def pytest_runtest_makereport(item, call): diff --git a/src/screens/base_screen.py b/src/screens/base_screen.py index 4dcc5c7..87ac046 100644 --- a/src/screens/base_screen.py +++ b/src/screens/base_screen.py @@ -5,17 +5,15 @@ Locator = Tuple[str, str] +type Condition = Literal["clickable", "visible", "present"] +type Direction = Literal["down", "up"] class Screen(ElementInteractor): def __init__(self, driver): super().__init__(driver) - def click( - self, - locator: Locator, - condition: Literal["clickable", "visible", "present"] = "clickable", - ): + def click(self, locator: Locator, condition: Condition = "clickable"): element = self.element(locator, condition=condition) element.click() @@ -61,7 +59,7 @@ def swipe( def scroll( self, - directions: Literal["down", "up"] = "down", + directions: Direction = "down", start_ratio: float = 0.7, end_ratio: float = 0.3, ): @@ -111,7 +109,7 @@ def scroll_to_element( def scroll_until_element_visible( self, destination_el: Locator, - directions: Literal["down", "up"] = "down", + directions: Direction = "down", start_ratio: float = 0.6, end_ratio: float = 0.3, retries: int = 1, @@ -126,10 +124,7 @@ def type(self, locator: Locator, text: str): element.send_keys(text) def double_tap( - self, - locator: Locator, - condition: Literal["clickable", "visible", "present"] = "clickable", - **kwargs, + self, locator: Locator, condition: Condition = "clickable", **kwargs ): """Double taps on an element.""" try: diff --git a/src/screens/element_interactor.py b/src/screens/element_interactor.py index 37bf223..810a462 100644 --- a/src/screens/element_interactor.py +++ b/src/screens/element_interactor.py @@ -7,7 +7,7 @@ from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions.pointer_input import PointerInput from selenium.webdriver.remote.webelement import WebElement -from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support.wait import WebDriverWait from selenium.common.exceptions import TimeoutException, NoSuchElementException @@ -33,7 +33,7 @@ def __init__(self, driver): self.waiters[WaitType.FLUENT] = WebDriverWait( driver, WaitType.FLUENT.value, poll_frequency=1 ) - + def _get_waiter(self, wait_type: Optional[WaitType] = None) -> WebDriverWait: """Returns the appropriate waiter based on the given wait_type.""" return self.waiters.get(wait_type, self.waiters[WaitType.DEFAULT]) @@ -46,9 +46,9 @@ def wait_for( ) -> WebElement: waiter = waiter or self._get_waiter() conditions = { - "clickable": EC.element_to_be_clickable(locator), - "visible": EC.visibility_of_element_located(locator), - "present": EC.presence_of_element_located(locator), + "clickable": ec.element_to_be_clickable(locator), + "visible": ec.visibility_of_element_located(locator), + "present": ec.presence_of_element_located(locator), } if condition not in conditions: raise ValueError(f"Unknown condition: {condition}") diff --git a/tests/test_p1/test_actions.py b/tests/test_p1/test_actions.py index 633691c..3eaabaf 100644 --- a/tests/test_p1/test_actions.py +++ b/tests/test_p1/test_actions.py @@ -2,7 +2,7 @@ from screens.main_screen.main_screen import MainScreen -class TestClick: +class TestBaseActions: @pytest.fixture(autouse=True) def setup(self, driver) -> None: """Setup common objects for tests after address is set."""