diff --git a/config/settings.yaml b/config/settings.yaml index ddf2cb8..b50ed81 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -9,7 +9,7 @@ stage: autoGrantPermissions: True appWaitForLaunch: True maxRetryCount: 40 - noReset: True + noReset: False appWaitDuration: 30000 appPackage: "io.appium.android.apis" appActivity: "io.appium.android.apis.ApiDemos" diff --git a/conftest.py b/conftest.py index d7d73c2..c38b5ff 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 event_driver + yield driver - if event_driver is not None: - event_driver.quit() + if driver is not None: + driver.quit() # def pytest_runtest_makereport(item, call): diff --git a/src/drivers/event_listener.py b/src/drivers/event_listener.py index 2412a1a..40552a1 100644 --- a/src/drivers/event_listener.py +++ b/src/drivers/event_listener.py @@ -29,4 +29,4 @@ def after_quit(self, driver): logger.info("Driver has quit.") def on_exception(self, exception, driver) -> None: - logger.info(f"On exception") \ No newline at end of file + logger.info(f"On exception") diff --git a/src/locators/__init__.py b/src/locators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/locators/locators.py b/src/locators/locators.py new file mode 100644 index 0000000..b918a81 --- /dev/null +++ b/src/locators/locators.py @@ -0,0 +1,7 @@ +from appium.webdriver.common.appiumby import AppiumBy + + +class Common: + text_link = (AppiumBy.ACCESSIBILITY_ID, 'Text') + content_link = (AppiumBy.ACCESSIBILITY_ID, 'Content') + menu_elements = (AppiumBy.XPATH, '//android.widget.TextView') \ No newline at end of file diff --git a/src/screens/base_screen.py b/src/screens/base_screen.py index ba13b2f..ee5036d 100644 --- a/src/screens/base_screen.py +++ b/src/screens/base_screen.py @@ -1,7 +1,9 @@ import time -from typing import Tuple +from typing import Tuple, Literal from screens.element_interactor import ElementInteractor +from appium.webdriver.extensions.action_helpers import ActionHelpers, ActionChains + Locator = Tuple[str, str] @@ -10,12 +12,20 @@ class Screen(ElementInteractor): def __init__(self, driver): super().__init__(driver) - def click(self): - pass + def click( + self, + locator: Locator, + condition: Literal["clickable", "visible", "present"] = "clickable", + ): + element = self.element(locator, condition=condition) + element.click() + + def tap(self, locator, **kwargs): + element = self.element(locator, condition="clickable", **kwargs) + self.driver.tap() + action_helpers = ActionHelpers() + action_helpers.tap(element) - def tap(self): - pass - def tap_by_coordinates(self): pass @@ -40,7 +50,7 @@ def sleep(kwargs): def get_screen_size(self): return self.driver.get_window_size() - + def back(self): self.driver.back() @@ -51,4 +61,4 @@ def reset(self): self.driver.reset() def launch_app(self): - self.driver.launch_app() \ No newline at end of file + self.driver.launch_app() diff --git a/src/screens/element_interactor.py b/src/screens/element_interactor.py index ce1d148..e198ef9 100644 --- a/src/screens/element_interactor.py +++ b/src/screens/element_interactor.py @@ -1,23 +1,15 @@ -import time from enum import Enum from typing import Tuple, Optional, Literal, List +import time 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, - ElementNotVisibleException, - NoSuchElementException, -) +from selenium.common.exceptions import TimeoutException, NoSuchElementException Locator = Tuple[str, str] class WaitType(Enum): - """ - Enumeration for different wait durations used in WebDriverWait. - """ - DEFAULT = 30 SHORT = 5 LONG = 60 @@ -25,40 +17,18 @@ class WaitType(Enum): class ElementInteractor: - """ - A utility class for interacting with screen elements, waits strategy. - """ - def __init__(self, driver): - """ - Initializes the ElementInteractor with a WebDriver instance and predefined waiters. - - :param driver: The Selenium WebDriver instance to interact with. - :type driver: WebDriver - """ self.driver = driver self.waiters = { - WaitType.DEFAULT: WebDriverWait(driver, WaitType.DEFAULT.value), - WaitType.SHORT: WebDriverWait(driver, WaitType.SHORT.value), - WaitType.LONG: WebDriverWait(driver, WaitType.LONG.value), - WaitType.FLUENT: WebDriverWait( - driver, - WaitType.FLUENT.value, - poll_frequency=1, - ignored_exceptions=[ElementNotVisibleException], - ), + wait_type: WebDriverWait(driver, wait_type.value) + for wait_type in WaitType + if wait_type != WaitType.FLUENT } + 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 WebDriverWait instance based on the specified wait type. - - :param wait_type: The type of wait (default is `WaitType.DEFAULT`). - :type wait_type: Optional[WaitType] - - :return: The WebDriverWait instance for the specified wait type. - :rtype: WebDriverWait - """ return self.waiters.get(wait_type, self.waiters[WaitType.DEFAULT]) def wait_for( @@ -67,33 +37,40 @@ def wait_for( condition: Literal["clickable", "visible", "present"] = "visible", waiter: Optional[WebDriverWait] = None, ) -> WebElement: - """ - Waits for an element to meet the specified condition. - - :param locator: A tuple containing the strategy and value of the element locator. - :param condition: The condition to wait for ("clickable", "visible", or "present"). - :param waiter: A custom WebDriverWait instance. Defaults to `None`, which uses the default waiter. - - :return: The located web element once the condition is satisfied. - """ 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}") - try: return waiter.until(conditions[condition]) except TimeoutException as e: raise TimeoutException( - f"Condition '{condition}' failed for element {locator} " - f"after {waiter._timeout} seconds" + f"Condition '{condition}' failed for element {locator} after {waiter._timeout} seconds" ) from e + def element( + self, + locator: Locator, + n: int = 3, + condition: Literal["clickable", "visible", "present"] = "visible", + wait_type: Optional[WaitType] = WaitType.DEFAULT, + ): + for attempt in range(1, n + 1): + try: + self.wait_for( + locator, condition=condition, waiter=self._get_waiter(wait_type) + ) + return self.driver.find_element(*locator) + except NoSuchElementException: + if attempt == n: + raise NoSuchElementException( + f"Could not locate element with value: {locator}" + ) + def elements( self, locator: Locator, @@ -101,16 +78,6 @@ def elements( condition: Literal["clickable", "visible", "present"] = "visible", wait_type: Optional[WaitType] = WaitType.DEFAULT, ) -> List[WebElement]: - """ - Attempts to locate a list of elements by polling a maximum of 'n' times. - - :param locator: A tuple containing the strategy and value of the element locator. - :param n: The maximum number of attempts to find the elements. Default is 3. - :param condition: The condition to wait for ("clickable", "visible", or "present"). - :param wait_type: The wait type to use for polling. Defaults to `WaitType.DEFAULT`. - - :return: A list of located web elements that match the condition. - """ for attempt in range(1, n + 1): try: self.wait_for( @@ -122,35 +89,6 @@ def elements( raise NoSuchElementException( f"Could not locate element list with value: {locator}" ) - except Exception: - if attempt == n: - raise - - def _assert_element_displayed(self, element: WebElement, expected: bool) -> None: - """ - Asserts that the element's displayed status matches the expected value. - - :param element: The web element to check. - :param expected: The expected visibility status of the element (True or False). - - :raises AssertionError: If the element's visibility does not match the expected value. - """ - assert element.is_displayed() == expected - - def _check_elements_displayed( - self, elements: List[WebElement], expected: bool, index: Optional[int] = None - ) -> bool: - """ - Checks if the elements are displayed and if applicable, checks a specific element by index. - - :param elements: The list of web elements to check. - :param expected: The expected visibility status of the elements (True or False). - :param index: The index of the specific element to check. If `None`, all elements are checked. - :return: True if the element(s) are displayed with the expected status, otherwise False. - """ - if index is None: - return all(e.is_displayed() == expected for e in elements) - return elements[index].is_displayed() == expected def is_displayed( self, @@ -160,15 +98,6 @@ def is_displayed( condition: Literal["clickable", "visible", "present"] = "visible", wait_type: Optional[WaitType] = None, ) -> None: - """Checks for an element to be displayed or not, and asserts the visibility. - - :param locator: A tuple containing the strategy and value of the element locator. - :param expected: The expected visibility status of the element (True or False). - :param n: The maximum number of attempts to check visibility. Defaults to 3. - :param condition: The condition to wait for ("clickable", "visible", or "present"). - :param wait_type: The wait type to use for polling. Defaults to `WaitType.DEFAULT`. - - :raises AssertionError: If the element's visibility does not match the expected value""" wait_type = wait_type or WaitType.DEFAULT for _ in range(n): try: @@ -179,7 +108,12 @@ def is_displayed( return except Exception: time.sleep(0.5) - assert False == expected + if expected: # Assert if the element is expected to be displayed but isn't + raise AssertionError(f"Element {locator} was not displayed as expected.") + else: # Assert if the element should not be displayed but is + raise AssertionError( + f"Element {locator} was displayed when it shouldn't be." + ) def is_exist( self, @@ -188,30 +122,17 @@ def is_exist( n: int = 3, condition: Literal["clickable", "visible", "present"] = "visible", wait_type: Optional[WaitType] = WaitType.DEFAULT, - **kwargs, ) -> bool: - """ - Checks for an element's existence and checks if it meets the expected visibility status. - - :param locator: A tuple containing the strategy and value of the element locator. - :param expected: The expected existence status of the element (True or False). - :param n: The maximum number of attempts to check existence. Defaults to 3. - :param condition: The condition to wait for ("clickable", "visible", or "present"). - :param wait_type: The wait type to use for polling. Defaults to `WaitType.DEFAULT`. - :param **kwargs: Additional keyword arguments, such as `index` for checking a specific element in a list. - - :return: `True` if the element(s) exist and match the expected visibility status, otherwise `False`. - :rtype: bool - """ for _ in range(n): try: - elements = self.wait_for( - locator, condition=condition, waiter=self._get_waiter(wait_type) + element = self.element( + locator, n=1, condition=condition, wait_type=wait_type ) - if isinstance(elements, list) and self._check_elements_displayed( - elements, expected, kwargs.get("index") - ): + return element.is_displayed() == expected + except NoSuchElementException: + if not expected: return True except Exception: - if _ == n - 1: - return False + pass + time.sleep(0.5) + return not expected diff --git a/src/screens/main/__init__.py b/src/screens/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/screens/main/main_screen.py b/src/screens/main/main_screen.py new file mode 100644 index 0000000..476db3f --- /dev/null +++ b/src/screens/main/main_screen.py @@ -0,0 +1,11 @@ +from locators.locators import Common +from screens.base_screen import Screen + + +class MainScreen(Screen): + + def __init__(self, driver): + super().__init__(driver) + + def click_on_text_link(self): + self.click(locator = Common.text_link) \ No newline at end of file diff --git a/src/utils/logger.py b/src/utils/logger.py index e69de29..4ab6c47 100644 --- a/src/utils/logger.py +++ b/src/utils/logger.py @@ -0,0 +1,147 @@ +import logging +import os +import time +from enum import Enum +from typing import Optional, Callable, Any, Literal + + +class LogLevel(Enum): + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARNING + ERROR = logging.ERROR + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs) -> Any: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class Logger(metaclass=Singleton): + def __init__( + self, + log_lvl: LogLevel = LogLevel.INFO, + log_base_directory: Optional[str] = None, + log_mode: str = "w", # 'w' for overwrite, 'a' for append + console_logging: bool = True, + ) -> None: + self._log = logging.getLogger("playwrite") + self._log.setLevel(LogLevel.DEBUG.value) + + self.log_base_directory = log_base_directory or os.path.abspath( + os.path.join(os.path.dirname(__file__), "../..") + ) + self.log_file = self._create_log_file() + self._initialize_logging(log_lvl, log_mode, console_logging) + + def _create_log_file(self) -> str: + current_time = time.strftime("%Y-%m-%d") + log_directory = os.path.join(self.log_base_directory, "reports/logs") + + os.makedirs( + log_directory, exist_ok=True + ) # Create directory if it doesn't exist + + return os.path.join(log_directory, f"log_{current_time}.log") + + def _initialize_logging( + self, log_lvl: LogLevel, log_mode: str, console_logging: bool + ) -> None: + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + # File handler + fh = logging.FileHandler(self.log_file, mode=log_mode) + fh.setFormatter(formatter) + fh.setLevel(log_lvl.value) + self._log.addHandler(fh) + + # Console handler + if console_logging: + ch = logging.StreamHandler() + ch.setFormatter(formatter) + ch.setLevel(log_lvl.value) + self._log.addHandler(ch) + + def get_instance(self) -> logging.Logger: + return self._log + + def annotate( + self, message: str, level: Literal["info", "warn", "debug", "error"] = "info" + ) -> None: + """Log a message at the specified level.""" + log_methods = { + "info": self._log.info, + "warn": self._log.warning, + "debug": self._log.debug, + "error": self._log.error, + } + + if level not in log_methods: + raise ValueError(f"Invalid log level: {level}") + + log_methods[level](message) + + +def log( + data: Optional[str] = None, + level: Literal["info", "warn", "debug", "error"] = "info", +) -> Callable: + """Decorator to log the current method's execution. + + :param data: Custom log message to use if no docstring is provided. + :param level: Level of the logs, e.g., info, warn, debug, error. + """ + logger_instance = Logger() + + def decorator(func: Callable) -> Callable: + def wrapper(self, *args, **kwargs) -> Any: + # Get the method's docstring + method_docs = format_method_doc_str(func.__doc__) + + if method_docs is None and data is None: + raise ValueError(f"No documentation available for :: {func.__name__}") + + # Construct the parameter string for logging + params_str = ", ".join(repr(arg) for arg in args) + kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items()) + all_params_str = ", ".join(filter(None, [params_str, kwargs_str])) + + # Filter out unwanted information + filtered_params_str = filter_locator_info(all_params_str) + + logs = ( + f"{method_docs + '.' if method_docs else data} " + f"Method :: {func.__name__}() " + f"with parameters: {filtered_params_str}" + ) + + logger_instance.annotate(logs, level) + + # Call the original method, passing *args and **kwargs + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +def format_method_doc_str(doc_str: Optional[str]) -> Optional[str]: + """Add a dot to the docs string if it doesn't exist.""" + if doc_str and not doc_str.endswith("."): + return doc_str + "." + return doc_str + + +def filter_locator_info(param_str: str) -> str: + """Filter out unwanted details from the parameters string.""" + # Example regex to filter out the specific Locator format, modify as needed + import re + + filtered = re.sub(r"", "", param_str) + return filtered.strip() \ No newline at end of file diff --git a/tests/test_p1/test_p1.py b/tests/test_p1/test_p1.py index 33fb9bb..c19c875 100644 --- a/tests/test_p1/test_p1.py +++ b/tests/test_p1/test_p1.py @@ -1,3 +1,7 @@ -class Test1: - def test_1(self, driver): - pass +from screens.main.main_screen import MainScreen + + +class TestClick: + def test_click(self, driver): + main = MainScreen(driver) + main.click_on_text_link()