From 6d75b8411e426352ab7444736d4f0a6aa1a5db41 Mon Sep 17 00:00:00 2001 From: "dmy.berezovskyi" Date: Thu, 13 Mar 2025 20:50:41 +0200 Subject: [PATCH] Added swipe to delete, logging, double tap, screenshot on exception --- conftest.py | 45 ++++++++------- src/drivers/driver_factory.py | 6 ++ src/drivers/event_listener.py | 25 ++++----- src/drivers/ios_driver.py | 2 +- src/locators/locators.py | 12 +++- src/screens/base_screen.py | 77 +++++++++++++++++++------- src/screens/element_interactor.py | 35 +++++++++++- src/screens/main_screen/main_screen.py | 33 +++++++++++ src/utils/logger.py | 4 +- tests/test_p1/test_actions.py | 12 ++++ 10 files changed, 188 insertions(+), 63 deletions(-) diff --git a/conftest.py b/conftest.py index d7d73c2..ae0c4f0 100644 --- a/conftest.py +++ b/conftest.py @@ -5,6 +5,9 @@ from drivers.driver_factory import Driver from drivers.event_listener import AppEventListener +from utils.logger import Logger, LogLevel + +log = Logger(log_lvl=LogLevel.INFO).get_instance() @pytest.hookimpl @@ -68,24 +71,24 @@ def driver(request): event_driver.quit() -# def pytest_runtest_makereport(item, call): -# """Capture screenshot on test failure.""" -# if call.excinfo is not None: -# driver = item.funcargs.get("driver", None) -# -# if driver is not None: -# screenshot_dir = "reports/screenshots" -# os.makedirs( -# screenshot_dir, exist_ok=True -# ) # Create directory if it does not exist -# screenshot_path = os.path.join(screenshot_dir, f"{item.name}.png") -# -# try: -# driver.save_screenshot(screenshot_path) -# # log.info(f"Screenshot saved to: {screenshot_path}") -# except Exception as e: -# pass -# # log.error(f"Failed to save screenshot: {e}") -# else: -# pass -# # log.error("Driver instance is not available for capturing screenshot.") +def pytest_runtest_makereport(item, call): + """Capture screenshot on test failure.""" + if call.excinfo is not None: + driver = item.funcargs.get("driver", None) + + if driver is not None: + screenshot_dir = "reports/screenshots" + os.makedirs( + screenshot_dir, exist_ok=True + ) # Create directory if it does not exist + screenshot_path = os.path.join(screenshot_dir, f"{item.name}.png") + + try: + driver.save_screenshot(screenshot_path) + log.info(f"Screenshot saved to: {screenshot_path}") + except Exception as e: + pass + log.error(f"Failed to save screenshot: {e}") + else: + pass + log.error("Driver instance is not available for capturing screenshot.") diff --git a/src/drivers/driver_factory.py b/src/drivers/driver_factory.py index 46e2f6d..3841646 100644 --- a/src/drivers/driver_factory.py +++ b/src/drivers/driver_factory.py @@ -5,6 +5,9 @@ from config import settings from drivers.android_driver import AndroidCaps from drivers.ios_driver import IOSCaps +from utils.logger import Logger, LogLevel + +log = Logger(log_lvl=LogLevel.INFO).get_instance() class Driver: @@ -18,12 +21,15 @@ def get_driver(platform: str): ) if not caps: + log.info(f"Capabilities not found for platform ❌: {platform}") raise ValueError(f"Capabilities not found for platform ❌: {platform}") if platform.lower() == "android": options = UiAutomator2Options().load_capabilities(caps) + log.info(f"Capabilities: {options}") else: options = XCUITestOptions().load_capabilities(caps) + log.info(f"Capabilities: {options}") driver = webdriver.Remote(settings.APPIUM_SERVER, options=options) return driver diff --git a/src/drivers/event_listener.py b/src/drivers/event_listener.py index 40552a1..ca93665 100644 --- a/src/drivers/event_listener.py +++ b/src/drivers/event_listener.py @@ -2,31 +2,28 @@ from selenium.webdriver.support.abstract_event_listener import AbstractEventListener -# TODO make logger -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) +from utils.logger import Logger, LogLevel + +log = Logger(log_lvl=LogLevel.INFO).get_instance() class AppEventListener(AbstractEventListener): """Custom Event Listener for Appium WebDriver.""" - def before_find(self, by, value, driver): - logger.info(f"Looking for element: {by} -> {value}") + # def before_find(self, by, value, driver): + # logger.info(f"Looking for element: {by} -> {value}") def after_find(self, by, value, driver): - logger.info(f"Found element: {by} -> {value}") + log.info(f"Found element: {by} -> {value}") - def before_click(self, element, driver): - logger.info(f"Before clicking: {element}") + # def before_click(self, element, driver): + # logger.info(f"Before clicking: {element}") def after_click(self, element, driver): - logger.info(f"Clicked on: {element}") - - def before_quit(self, driver): - logger.info("Driver is about to quit.") + log.info(f"Clicked on: {element}") def after_quit(self, driver): - logger.info("Driver has quit.") + log.info("Driver has quit.") def on_exception(self, exception, driver) -> None: - logger.info(f"On exception") + log.info(f"On exception") diff --git a/src/drivers/ios_driver.py b/src/drivers/ios_driver.py index 160bf9f..ebc4f5a 100644 --- a/src/drivers/ios_driver.py +++ b/src/drivers/ios_driver.py @@ -10,7 +10,7 @@ def get_caps(): caps = settings.iOS.to_dict() if not caps: - raise ValueError("❌ ANDROID capabilities not found in settings.yaml") + raise ValueError("❌ iOS capabilities not found in settings.yaml") caps["app"] = str(Path(__file__).resolve().parents[2] / "data/apps/demo.ipa") diff --git a/src/locators/locators.py b/src/locators/locators.py index 8f1c63f..f4e05b9 100644 --- a/src/locators/locators.py +++ b/src/locators/locators.py @@ -13,6 +13,14 @@ class views_menu: ANIMATION_LINK = (AppiumBy.ACCESSIBILITY_ID, "Animation") GALLERY_LINK = (AppiumBy.ACCESSIBILITY_ID, "Gallery") IMAGE_BUTTON = (AppiumBy.ACCESSIBILITY_ID, "ImageButton") + TABS_LINK = (AppiumBy.ACCESSIBILITY_ID, "Tabs") - class views_fields: - HINT_INPUT = (AppiumBy.ACCESSIBILITY_ID, "hint") + class text_fields: + HINT_INPUT = (AppiumBy.ID, "io.appium.android.apis:id/edit") + + class tabs_fields: + SCROLLABLE_LINK = (AppiumBy.ACCESSIBILITY_ID, "5. Scrollable") + SCROLLABLE_TAB = ( + AppiumBy.XPATH, + '//android.widget.TextView[@resource-id="android:id/title" and @text="TAB 2"]', + ) diff --git a/src/screens/base_screen.py b/src/screens/base_screen.py index 87ac046..7652487 100644 --- a/src/screens/base_screen.py +++ b/src/screens/base_screen.py @@ -1,7 +1,10 @@ import time from typing import Tuple, Literal +from selenium.webdriver import ActionChains + from screens.element_interactor import ElementInteractor +from utils.logger import log Locator = Tuple[str, str] @@ -14,6 +17,7 @@ def __init__(self, driver): super().__init__(driver) def click(self, locator: Locator, condition: Condition = "clickable"): + """Click on element""" element = self.element(locator, condition=condition) element.click() @@ -36,27 +40,61 @@ def tap(self, locator: Locator, duration: float = 500, **kwargs): def swipe( self, - relative_start_x: float, - relative_start_y: float, - relative_end_x: float, - relative_end_y: float, + start_ratio: Tuple[float, float], + end_ratio: Tuple[float, float], duration_ms: int = 200, ) -> None: + """Performs a swipe gesture based on screen size ratios. + + :param start_ratio: (x, y) tuple for the starting position (0-1 range) + :param end_ratio: (x, y) tuple for the ending position (0-1 range) + :param duration_ms: Swipe duration in milliseconds (default: 200ms) + Usage: + Swipe left self.swipe((0.9, 0.5), (0.1, 0.5)) + + """ size = self.get_screen_size() - width = size["width"] - height = size["height"] - start_x = int(width * relative_start_x) - start_y = int(height * relative_start_y) - end_x = int(width * relative_end_x) - end_y = int(height * relative_end_y) - self.driver.swipe( - start_x=start_x, - start_y=start_y, - end_x=end_x, - end_y=end_y, - duration_ms=duration_ms, + start_x, start_y = ( + int(size["width"] * start_ratio[0]), + int(size["height"] * start_ratio[1]), + ) + end_x, end_y = ( + int(size["width"] * end_ratio[0]), + int(size["height"] * end_ratio[1]), ) + self.driver.swipe(start_x, start_y, end_x, end_y, duration=duration_ms) + + def swipe_to_delete( + self, + locator: Locator, + direction: Literal["left", "right"], + duration_ms: int = 500, + start_ratio: float = 0.8, + end_ratio: float = 0.2, + ): + """Swipes an element left or right to trigger a delete action. + + :param locator: The locator of the element to swipe. + :param direction: "left" or "right" to define the swipe direction. + :param duration_ms: Duration of the swipe in milliseconds. + :param start_ratio: Start position as a percentage of element width. + :param end_ratio: End position as a percentage of element width. + """ + element = self.element(locator) + location = element.location + size = element.size + + start_x = location["x"] + size["width"] * ( + start_ratio if direction == "left" else (1 - start_ratio) + ) + end_x = location["x"] + size["width"] * ( + end_ratio if direction == "left" else (1 - end_ratio) + ) + start_y = location["y"] + size["height"] // 2 + + self.driver.swipe(start_x, start_y, end_x, start_y, duration_ms) + def scroll( self, directions: Direction = "down", @@ -121,6 +159,7 @@ def scroll_until_element_visible( def type(self, locator: Locator, text: str): element = self.element(locator) + element.clear() element.send_keys(text) def double_tap( @@ -128,14 +167,10 @@ def double_tap( ): """Double taps on an element.""" try: - element = self.element(locator, condition=condition, **kwargs) - # TODO + self.double_tap_actions(locator, condition=condition, **kwargs) except Exception as e: print(f"Error during double tap action: {e}") - def long_press(self): - pass - @staticmethod def sleep(kwargs): try: diff --git a/src/screens/element_interactor.py b/src/screens/element_interactor.py index 810a462..d69b5af 100644 --- a/src/screens/element_interactor.py +++ b/src/screens/element_interactor.py @@ -16,7 +16,8 @@ class WaitType(Enum): - DEFAULT = 30 + DEFAULT = 15 + SHORTEST = 2 SHORT = 5 LONG = 60 FLUENT = 10 @@ -128,7 +129,7 @@ def is_exist( expected: bool = True, n: int = 3, condition: Condition = "visible", - wait_type: Optional[WaitType] = WaitType.SHORT, + wait_type: Optional[WaitType] = WaitType.SHORTEST, retry_delay: float = 0.5, ) -> bool: """ @@ -203,3 +204,33 @@ def scroll_by_coordinates( actions.w3c_actions.pointer_action.release() actions.perform() + + def double_tap_actions( + self, + locator, + condition: Condition = "clickable", + index: Optional[int] = None, + n: int = 2, + ): + """ + Performs a double tap using ActionChains. + + - Waits for the element(s) to be visible + - Works for both single and multiple elements (use index for multiple) + + :param condition: + :param locator: Tuple (By, value) + :param index: Index of element in case of multiple elements + :param n: Number of attempts to locate element + """ + elements = self.elements(locator, condition=condition, n=n) + + if not elements: + raise NoSuchElementException( + f"Could not locate element with value: {locator}" + ) + + element = elements[index] if index is not None else elements[0] + + actions = ActionChains(self.driver) + actions.double_click(element).perform() diff --git a/src/screens/main_screen/main_screen.py b/src/screens/main_screen/main_screen.py index 61ab574..fc2c5cd 100644 --- a/src/screens/main_screen/main_screen.py +++ b/src/screens/main_screen/main_screen.py @@ -2,6 +2,7 @@ from locators.locators import Locators from screens.base_screen import Screen +from utils.logger import log class MainScreen(Screen): @@ -10,16 +11,20 @@ def __init__(self, driver): self.locators = Locators() def click_on_text_link(self): + """Click on text link""" self.click(locator=self.locators.main_menu.TEXT_LINK) def tap_on_text_link(self): + """Tap on text link""" self.tap(locator=self.locators.main_menu.TEXT_LINK) def scroll_view_by_coordinates(self, direction: Literal["down", "up"] = "down"): + """Scroll by coordinates""" self.tap(locator=self.locators.main_menu.VIEWS_LINK) self.scroll(directions=direction) def scroll_to_image_button(self): + """Scroll to image button""" self.tap(locator=self.locators.main_menu.VIEWS_LINK) self.scroll_to_element( from_el=self.locators.views_menu.ANIMATION_LINK, @@ -27,7 +32,35 @@ def scroll_to_image_button(self): ) def scroll_until_text_field_visible(self): + """Scroll until element visible""" self.tap(locator=self.locators.main_menu.VIEWS_LINK) self.scroll_until_element_visible( destination_el=self.locators.views_menu.TEXT_FIELDS ) + + def swipe_tab(self): + """Move to Scrollable tab and swipe left""" + self.tap(locator=self.locators.main_menu.VIEWS_LINK) + self.scroll_until_element_visible( + destination_el=self.locators.views_menu.TABS_LINK + ) + self.tap(locator=self.locators.views_menu.TABS_LINK) + self.tap(locator=self.locators.views_menu.tabs_fields.SCROLLABLE_LINK) + self.swipe_to_delete( + locator=self.locators.views_menu.tabs_fields.SCROLLABLE_TAB, + direction="left", + ) + + def type_text(self, text): + """Type text to field with HINT""" + self.tap(locator=self.locators.main_menu.VIEWS_LINK) + self.scroll_until_element_visible( + destination_el=self.locators.views_menu.TEXT_FIELDS + ) + self.tap(locator=self.locators.views_menu.TEXT_FIELDS) + self.click(locator=self.locators.views_menu.text_fields.HINT_INPUT) + self.type(locator=self.locators.views_menu.text_fields.HINT_INPUT, text=text) + + def double_tap_on_views_link(self): + """Double tap""" + self.double_tap(locator=self.locators.main_menu.VIEWS_LINK) diff --git a/src/utils/logger.py b/src/utils/logger.py index 4ab6c47..421684c 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -29,7 +29,7 @@ def __init__( log_mode: str = "w", # 'w' for overwrite, 'a' for append console_logging: bool = True, ) -> None: - self._log = logging.getLogger("playwrite") + self._log = logging.getLogger("mobile") self._log.setLevel(LogLevel.DEBUG.value) self.log_base_directory = log_base_directory or os.path.abspath( @@ -144,4 +144,4 @@ def filter_locator_info(param_str: str) -> str: import re filtered = re.sub(r"", "", param_str) - return filtered.strip() \ No newline at end of file + return filtered.strip() diff --git a/tests/test_p1/test_actions.py b/tests/test_p1/test_actions.py index 3eaabaf..790ecab 100644 --- a/tests/test_p1/test_actions.py +++ b/tests/test_p1/test_actions.py @@ -1,3 +1,5 @@ +import time + import pytest from screens.main_screen.main_screen import MainScreen @@ -23,3 +25,13 @@ def test_sroll_to_element(self, setup): def test_scroll_util_visible(self, setup): self.main_screen.scroll_until_text_field_visible() + + def test_swipe_to_delete(self, setup): + self.main_screen.swipe_tab() + + def test_type_text(self, setup): + self.main_screen.type_text(text = 'text typing') + + def test_double_tap_views_link(self, setup): + self.main_screen.double_tap_on_views_link() + time.sleep(3) \ No newline at end of file