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 @@ -62,30 +65,30 @@
except Exception as e:
pytest.fail(f"Failed to initialize driver: {e}")

yield event_driver

Check warning on line 68 in conftest.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unbound local variables

Local variable 'event_driver' might be referenced before assignment

if event_driver is not None:
event_driver.quit()

Check warning on line 71 in conftest.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unbound local variables

Local variable 'event_driver' might be referenced before assignment


# 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,9 +5,12 @@
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:

Check notice on line 13 in src/drivers/driver_factory.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method
@staticmethod
def get_driver(platform: str):
"""Get driver by platform, uses appropriate capabilities for Android or iOS."""
Expand All @@ -18,12 +21,15 @@
)

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

Check notice on line 16 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Method is not declared static

Method `after_find` may be 'static'

Check notice on line 16 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unused local symbols

Parameter 'driver' value is not used
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):

Check notice on line 22 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Method is not declared static

Method `after_click` may be 'static'

Check notice on line 22 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unused local symbols

Parameter 'driver' value is not used
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):

Check notice on line 25 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Method is not declared static

Method `after_quit` may be 'static'

Check notice on line 25 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unused local symbols

Parameter 'driver' value is not used
logger.info("Driver has quit.")
log.info("Driver has quit.")

def on_exception(self, exception, driver) -> None:

Check notice on line 28 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Method is not declared static

Method `on_exception` may be 'static'

Check notice on line 28 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unused local symbols

Parameter 'driver' value is not used

Check notice on line 28 in src/drivers/event_listener.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Unused local symbols

Parameter 'exception' value is not used
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 @@ -3,14 +3,14 @@
from config import settings


class IOSCaps:

Check notice on line 6 in src/drivers/ios_driver.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method
@staticmethod
def get_caps():
"""Generate and return Android capabilities, with adding dynamic 'app' path."""
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
@@ -1,18 +1,26 @@
from appium.webdriver.common.appiumby import AppiumBy


class Locators:

Check notice on line 4 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method
class main_menu:

Check notice on line 5 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method

Check notice on line 5 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

PEP 8 naming convention violation

Class names should use CapWords convention
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:

Check notice on line 11 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method

Check notice on line 11 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

PEP 8 naming convention violation

Class names should use CapWords convention
TEXT_FIELDS = (AppiumBy.ACCESSIBILITY_ID, "TextFields")
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:

Check notice on line 18 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method

Check notice on line 18 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

PEP 8 naming convention violation

Class names should use CapWords convention
HINT_INPUT = (AppiumBy.ID, "io.appium.android.apis:id/edit")

class tabs_fields:

Check notice on line 21 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Class has no `__init__` method

Class has no __init__ method

Check notice on line 21 in src/locators/locators.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

PEP 8 naming convention violation

Class names should use CapWords convention
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,12 +1,15 @@
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]
type Condition = Literal["clickable", "visible", "present"]

Check warning on line 11 in src/screens/base_screen.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Invalid type hints definitions and usages

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types
type Direction = Literal["down", "up"]

Check warning on line 12 in src/screens/base_screen.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Invalid type hints definitions and usages

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types


class Screen(ElementInteractor):
Expand All @@ -14,6 +17,7 @@
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 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"],

Check warning on line 71 in src/screens/base_screen.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Invalid type hints definitions and usages

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types
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 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 @@ -12,11 +12,12 @@
from selenium.common.exceptions import TimeoutException, NoSuchElementException

Locator = Tuple[str, str]
type Condition = Literal["clickable", "visible", "present"]

Check warning on line 15 in src/screens/element_interactor.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Invalid type hints definitions and usages

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types


class WaitType(Enum):
DEFAULT = 30
DEFAULT = 15
SHORTEST = 2
SHORT = 5
LONG = 60
FLUENT = 10
Expand All @@ -27,7 +28,7 @@
self.driver = driver
self.waiters = {
wait_type: WebDriverWait(driver, wait_type.value)
for wait_type in WaitType

Check warning on line 31 in src/screens/element_interactor.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Incorrect type

Expected type 'collections.Iterable', got 'Type\[WaitType\]' instead
if wait_type != WaitType.FLUENT
}
self.waiters[WaitType.FLUENT] = WebDriverWait(
Expand Down Expand Up @@ -56,7 +57,7 @@
return waiter.until(conditions[condition])
except TimeoutException as e:
raise TimeoutException(
f"Condition '{condition}' failed for element {locator} after {waiter._timeout} seconds"

Check notice on line 60 in src/screens/element_interactor.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Accessing a protected member of a class or a module

Access to a protected member _timeout of a class
) from e

def element(
Expand Down Expand Up @@ -128,7 +129,7 @@
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 @@
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 @@
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"):

Check warning on line 21 in src/screens/main_screen/main_screen.py

View workflow job for this annotation

GitHub Actions / Qodana Community for Python

Invalid type hints definitions and usages

'Literal' may be parameterized with literal ints, byte and unicode strings, bools, Enum values, None, other literal types, or type aliases to other literal types
"""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
Loading