Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
6 changes: 6 additions & 0 deletions src/drivers/driver_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
25 changes: 11 additions & 14 deletions src/drivers/event_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
2 changes: 1 addition & 1 deletion src/drivers/ios_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
12 changes: 10 additions & 2 deletions src/locators/locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]',
)
77 changes: 56 additions & 21 deletions src/screens/base_screen.py
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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()

Expand All @@ -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",
Expand Down Expand Up @@ -121,21 +159,18 @@ 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(
self, locator: Locator, condition: Condition = "clickable", **kwargs
):
"""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:
Expand Down
35 changes: 33 additions & 2 deletions src/screens/element_interactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@


class WaitType(Enum):
DEFAULT = 30
DEFAULT = 15
SHORTEST = 2
SHORT = 5
LONG = 60
FLUENT = 10
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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()
33 changes: 33 additions & 0 deletions src/screens/main_screen/main_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from locators.locators import Locators
from screens.base_screen import Screen
from utils.logger import log


class MainScreen(Screen):
Expand All @@ -10,24 +11,56 @@ 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,
destination_el=self.locators.views_menu.IMAGE_BUTTON,
)

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)
Loading