diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..426f0ab --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,37 @@ +name: TestUI CI integration tests workflow + +on: + push: + branches: + - master + pull_request: + branches: + - master + + workflow_dispatch: + +jobs: + integration_test: + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@v3 + - name: setup python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: 'pip' + - name: install requirements + run: python -m pip install -r requirements.txt + - name: Analysing the code with pylint + run: | + pylint $(git ls-files '*.py') + - name: Remove Chrome + run: sudo apt purge google-chrome-stable + - name: Remove default Chromium + run: sudo apt purge chromium-browser + - name: Install a new Chromium + run: sudo apt install -y chromium-browser + - name: Integration browser test with Pytest + run: python -m pytest tests/selenium_tests.py diff --git a/.pylintrc b/.pylintrc index a03a90a..40d2477 100644 --- a/.pylintrc +++ b/.pylintrc @@ -9,6 +9,9 @@ extension-pkg-whitelist= # paths. ignore= +# Specify a score threshold to be exceeded before program exits with error. +fail-under=8.0 + # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= diff --git a/tests/appium_ios_app.py b/tests/appium_ios_app.py index f16375d..cee10ab 100644 --- a/tests/appium_ios_app.py +++ b/tests/appium_ios_app.py @@ -12,7 +12,8 @@ def appium_driver(self): NewDriver() .set_bundle_id("com.apple.Preferences") .set_platform("ios") - .set_udid("CC69C1D7-352E-4856-BFD0-B3E908747170") # Change UDID for iOS device + # Change UDID for iOS device + .set_udid("CC69C1D7-352E-4856-BFD0-B3E908747170") .set_logger() .set_appium_driver() ) diff --git a/tests/appium_tests.py b/tests/appium_tests.py index 2fd4e42..94ed6ac 100644 --- a/tests/appium_tests.py +++ b/tests/appium_tests.py @@ -30,7 +30,11 @@ def test_screenshot_methods(self, selenium_driver: TestUIDriver): ) selenium_driver.start_recording_screen() time.sleep(1) - selenium_driver.stop_recording_and_compare("./resources/comp.png", fps_reduction=30, keep_image_as="./logs/v-image.png") + selenium_driver.stop_recording_and_compare( + "./resources/comp.png", + fps_reduction=30, + keep_image_as="./logs/v-image.png", + ) selenium_driver.find_image_match( "./resources/comp.png", 0.9, True, image_match="./logs/image.png" ) diff --git a/tests/selenium_tests.py b/tests/selenium_tests.py index 821d69f..a178fd1 100644 --- a/tests/selenium_tests.py +++ b/tests/selenium_tests.py @@ -1,7 +1,6 @@ import pytest from selenium.webdriver.chrome.options import Options -from tests.screens.landing import LandingScreen from testui.support import logger from testui.support.appium_driver import NewDriver from testui.support.testui_driver import TestUIDriver @@ -26,19 +25,19 @@ def selenium_driver(self): @pytest.mark.signup def test_template_matching(self, selenium_driver: TestUIDriver): logger.log_test_name("T92701: Create an account") - selenium_driver.get_driver().set_window_size(1000, 1100) + selenium_driver.get_driver.set_window_size(1000, 1200) selenium_driver.navigate_to( "https://github.com/testdevlab/Py-TestUI#image-recognition" ) selenium_driver.find_image_match( - "resources/comp.png", 0.9, True, image_match="./logs/image.png" + "resources/comp.png", 0.1, True, image_match="./logs/image.png" ) selenium_driver.raise_errors() @pytest.mark.signup def test_get_dimensions(self, selenium_driver: TestUIDriver): logger.log_test_name("T92701: Create an account") - selenium_driver.get_driver().set_window_size(1000, 1100) + selenium_driver.get_driver.set_window_size(1000, 1100) selenium_driver.navigate_to( "https://github.com/testdevlab/Py-TestUI#image-recognition" ) diff --git a/testui/elements/testui_collection.py b/testui/elements/testui_collection.py index df5a9ce..bd2a074 100644 --- a/testui/elements/testui_collection.py +++ b/testui/elements/testui_collection.py @@ -8,21 +8,36 @@ class Error(Exception): - """Base class for exceptions in this module.""" + """Base class for exceptions in this module""" + # pylint: disable=super-init-not-called class CollectionException(Error): + """Exception raised for errors in the input""" + def __init__(self, message, expression=""): self.message = message self.expression = expression class Collections: + """ + Class for working with collections of elements + """ + def __init__(self, args): + """ + :param args: list of elements + """ self.args = args self.__errors = [] def wait_until_all_visible(self, seconds=10.0, log=True): + """ + Wait until all elements in collection are visible + :param seconds: timeout + :param log: log to console + """ start = time.time() threads = [] for arg in self.args: @@ -51,6 +66,12 @@ def wait_until_all_visible(self, seconds=10.0, log=True): ) def find_visible(self, seconds=10, return_el_number=False): + """ + Find first visible element in collection + :param seconds: timeout + :param return_el_number: return element number + :return: element + """ start = time.time() arg: Elements i = 0 @@ -64,8 +85,8 @@ def find_visible(self, seconds=10, return_el_number=False): ) if return_el_number: return arg, i - else: - return arg + + return arg i += 1 self.__show_error( f"{self.args[0].device_name}: No element within the collection was " @@ -73,6 +94,12 @@ def find_visible(self, seconds=10, return_el_number=False): ) def wait_until_attribute(self, attr_type: list, attr: list, seconds=10): + """ + Wait until all elements in collection have the correct attribute + :param attr_type: list of attribute types + :param attr: list of attributes + :param seconds: timeout + """ start = time.time() if len(attr_type) != len(self.args) or len(attr_type) != len(attr): raise Exception( @@ -112,18 +139,34 @@ def wait_until_attribute(self, attr_type: list, attr: list, seconds=10): ) def get(self, index: int): + """ + Get element by index + :param index: element index + :return: element + """ element: Elements = self.args[index] return element def __wait_until_attribute( self, element: Elements, attr_type, attr, seconds ): + """ + Wait until element has the correct attribute + :param element: element + :param attr_type: attribute type + :param attr: attribute + :param seconds: timeout + """ try: element.wait_until_attribute(attr_type, attr, seconds) except Exception as err: self.__errors.append(err) def __show_error(self, exception) -> None: + """ + Show error for provided expectation + :param exception: exception + """ driver = self.args[0].testui_driver config: Configuration = driver.configuration @@ -145,7 +188,8 @@ def __show_error(self, exception) -> None: def ee(*args) -> Collections: """ - locator types: + Create collection of elements + Available locator types: id, css, className, @@ -155,5 +199,7 @@ def ee(*args) -> Collections: uiautomator, classChain, predicate + :param args: list of elements + :return: collection of elements """ return Collections(args) diff --git a/testui/elements/testui_element.py b/testui/elements/testui_element.py index b7772ed..a20963d 100644 --- a/testui/elements/testui_element.py +++ b/testui/elements/testui_element.py @@ -16,6 +16,11 @@ def testui_error(driver, exception: str) -> None: + """ + Show error for provided expectation + :param driver: driver + :param exception: exception + """ config = driver.configuration exception += "\n" @@ -49,12 +54,16 @@ class Error(Exception): # pylint: disable=super-init-not-called class ElementException(Error): + """Element exception class for TestUI""" + def __init__(self, message, expression=""): self.message = message self.expression = expression class Elements: + """Elements class for TestUI""" + def __init__(self, driver, locator_type: str, locator: str): self.logger = driver.logger_name # TODO: Investigate if should be used in functionality or should be @@ -63,7 +72,7 @@ def __init__(self, driver, locator_type: str, locator: str): self.__soft_assert = driver.soft_assert self.testui_driver = driver self.device_name = driver.device_name - self.driver = driver.get_driver() + self.driver = driver.get_driver self.locator = locator self.locator_type = locator_type self.__is_collection = False @@ -75,6 +84,10 @@ def __init__(self, driver, locator_type: str, locator: str): self.__is_not = False def __put_log(self, message): + """ + Put log for provided expectation + :param message: message + """ if self.logger is not None: if self.logger == "behave": logger.log(f"{message} \n") @@ -82,15 +95,30 @@ def __put_log(self, message): logger.log(message) def __show_error(self, exception): + """ + Show error for provided expectation + :param exception: exception + """ testui_error(self.testui_driver, exception) def get(self, index): + """ + Get element by index + :param index: element index + :return: element + """ self.__is_collection = True self.index = index return self def get_element(self, index=0) -> WebElement: + """ + Get element, by default it will get the first element if no index is + provided. + :param index: element index + :return: element + """ if self.__is_collection: return self.__find_by_collection()[self.index] if index != 0: @@ -99,6 +127,10 @@ def get_element(self, index=0) -> WebElement: return self.__find_by_element() def __find_by_element(self) -> WebElement: + """ + Find element by locator + :return: element + """ if self.locator_type == "id": return self.driver.find_element(by=By.ID, value=self.locator) @@ -119,27 +151,37 @@ def __find_by_element(self) -> WebElement: return self.driver.find_element(By.XPATH, self.locator) if self.locator_type == "android_id_match": - return self.driver.find_element(AppiumBy.ANDROID_UIAUTOMATOR, - f"resourceIdMatches(\"{self.locator}\")" - ) + return self.driver.find_element( + AppiumBy.ANDROID_UIAUTOMATOR, + f'resourceIdMatches("{self.locator}")', + ) if self.locator_type == "accessibility": return self.driver.find_element( - AppiumBy.ACCESSIBILITY_ID, self.locator) + AppiumBy.ACCESSIBILITY_ID, self.locator + ) if self.locator_type == "uiautomator": return self.driver.find_element( - AppiumBy.ANDROID_UIAUTOMATOR, self.locator) + AppiumBy.ANDROID_UIAUTOMATOR, self.locator + ) if self.locator_type == "classChain": return self.driver.find_element( - AppiumBy.IOS_CLASS_CHAIN, self.locator) + AppiumBy.IOS_CLASS_CHAIN, self.locator + ) if self.locator_type == "predicate": - return self.driver.find_element(AppiumBy.IOS_PREDICATE, self.locator) + return self.driver.find_element( + AppiumBy.IOS_PREDICATE, self.locator + ) raise ElementException(f"locator not supported: {self.locator_type}") def __find_by_collection(self) -> List[WebElement]: + """ + Find multiple elements by locator + :return: elements + """ if self.locator_type == "id": return self.driver.find_elements(by=By.ID, value=self.locator) @@ -152,7 +194,6 @@ def __find_by_collection(self) -> List[WebElement]: return self.driver.find_elements( by=By.CLASS_NAME, value=self.locator ) - if self.locator_type == "name": return self.driver.find_elements(By.NAME, self.locator) @@ -160,27 +201,38 @@ def __find_by_collection(self) -> List[WebElement]: return self.driver.find_elements(By.XPATH, self.locator) if self.locator_type == "android_id_match": - return self.driver.find_elements(AppiumBy.ANDROID_UIAUTOMATOR, - f'resourceIdMatches("{self.locator}")' - ) + return self.driver.find_elements( + AppiumBy.ANDROID_UIAUTOMATOR, + f'resourceIdMatches("{self.locator}")', + ) if self.locator_type == "accessibility": - return self.driver.find_elements(AppiumBy.ACCESSIBILITY_ID, self.locator) + return self.driver.find_elements( + AppiumBy.ACCESSIBILITY_ID, self.locator + ) if self.locator_type == "uiautomator": return self.driver.find_elements( - AppiumBy.ANDROID_UIAUTOMATOR, - self.locator + AppiumBy.ANDROID_UIAUTOMATOR, self.locator ) if self.locator_type == "classChain": - return self.driver.find_elements(AppiumBy.IOS_CLASS_CHAIN, self.locator) + return self.driver.find_elements( + AppiumBy.IOS_CLASS_CHAIN, self.locator + ) if self.locator_type == "predicate": - return self.driver.find_elements(AppiumBy.IOS_PREDICATE, self.locator) + return self.driver.find_elements( + AppiumBy.IOS_PREDICATE, self.locator + ) raise ElementException(f"locator not supported: {self.locator_type}") def is_visible(self, log=True, **kwargs) -> bool: + """ + Check if element is visible + :param log: Boolean + :return: Boolean + """ is_not = False # Allows passing "is_not" as a kwarg to not overwrite self.__is_not. @@ -215,10 +267,15 @@ def is_visible(self, log=True, **kwargs) -> bool: if is_not: return not is_visible - else: - return is_visible - def is_visible_in(self, seconds): + return is_visible + + def is_visible_in(self, seconds) -> bool: + """ + Check if element is visible in a certain amount of time + :param seconds: seconds + :return: Boolean + """ start = time.time() is_not = self.__is_not while time.time() < start + seconds: @@ -228,6 +285,10 @@ def is_visible_in(self, seconds): return False def visible_for(self, seconds=1): + """ + Check if element is visible for a certain amount of time + :param seconds: seconds + """ start = time.time() is_not = self.__is_not err_text = "not" @@ -254,7 +315,12 @@ def visible_for(self, seconds=1): ) return self - def wait_until_visible(self, seconds=10.0, log=True) -> "Elements": + def wait_until_visible(self, seconds=10.0, log=True): + """ + Wait until element is visible + :param seconds: seconds + :param log: Boolean + """ start = time.time() is_not = self.__is_not @@ -287,6 +353,12 @@ def wait_until_visible(self, seconds=10.0, log=True) -> "Elements": return self def wait_until_attribute(self, attr, text, seconds=10): + """ + Wait until element has the correct attribute + :param attr: attribute + :param text: text + :param seconds: seconds + """ start = time.time() err = None value = "" @@ -318,6 +390,12 @@ def wait_until_attribute(self, attr, text, seconds=10): ) def wait_until_contains_attribute(self, attr, text, seconds=10): + """ + Wait until element has the correct attribute + :param attr: attribute + :param text: text + :param seconds: seconds + """ start = time.time() err = None value = "" @@ -327,7 +405,7 @@ def wait_until_contains_attribute(self, attr, text, seconds=10): while time.time() < start + seconds: try: value = self.get_element().get_attribute(attr) - if value.__contains__(text) != self.__is_not: + if text in value != self.__is_not: self.__put_log( f'element "{self.locator_type}: {self.locator}" has ' f'attribute "{attr}" {info_text} "{text}" after ' @@ -351,6 +429,13 @@ def wait_until_contains_attribute(self, attr, text, seconds=10): def wait_until_contains_sensitive_attribute( self, attr, text, seconds=10.0, log=True ): + """ + Wait until element has the correct attribute + :param attr: attribute + :param text: text + :param seconds: seconds + :param log: Boolean + """ start = time.time() err = None value = "" @@ -360,7 +445,7 @@ def wait_until_contains_sensitive_attribute( while time.time() < start + seconds: try: value = self.get_element().get_attribute(attr) - if value.lower().__contains__(text.lower()) != self.__is_not: + if text.lower() in value.lower() != self.__is_not: self.__put_log( f'{self.device_name}: element "{self.locator_type}: ' f'{self.locator}" has attribute "{attr}" -> "{value}" ' @@ -386,10 +471,15 @@ def wait_until_contains_sensitive_attribute( raise ElementException(err) def no(self, is_not=True): + """ + Set element to not + :param is_not: Boolean + """ self.__is_not = is_not return self def click(self): + """Click on element""" timeout = 5 # [seconds] start = time.time() @@ -413,6 +503,10 @@ def click(self): ) def press_hold_for(self, milliseconds=1000): + """ + Press and hold element for a certain amount of time + :param milliseconds: milliseconds + """ timeout = 5 # [seconds] start = time.time() @@ -422,7 +516,7 @@ def press_hold_for(self, milliseconds=1000): self.get_element() try: is_browser: str = self.testui_driver.context - if is_browser.__contains__("NATIVE"): + if "NATIVE" in is_browser: browser = False else: browser = True @@ -454,6 +548,11 @@ def press_hold_for(self, milliseconds=1000): ) def click_by_coordinates(self, x, y): + """ + Click on element by coordinates + :param x: x + :param y: y + """ timeout = 5 # [seconds] start = time.time() @@ -502,13 +601,16 @@ def screenshot(self, image_name="cropped_image.png"): try: self.get_element().screenshot(image_name) except Exception: - path_img = self.testui_driver.save_screenshot(f"{self.device_name}-crop_image.png") + path_img = self.testui_driver.save_screenshot( + f"{self.device_name}-crop_image.png" + ) dimensions = self.dimensions top_left = self.location - logger.log_debug(f'crop dimensions (x,y,w,h):({top_left.x},{top_left.y},{dimensions.x},{dimensions.y})') - ImageRecognition( - path_img - ).crop_original_image( + logger.log_debug( + "crop dimensions (x,y,w,h):" + f"({top_left.x},{top_left.y},{dimensions.x},{dimensions.y})" + ) + ImageRecognition(path_img).crop_original_image( (top_left.x + dimensions.x // 2), (top_left.y + dimensions.y // 2), dimensions.x, @@ -665,6 +767,12 @@ def swipe( ) def slide_percentage(self, percentage, start_x=None): + """ + Slides the element by a percentage of its width. + :param percentage: The percentage of the element's width to slide. + :param start_x: The starting x-coordinate of the slide. + """ + width = self.dimensions.x * percentage / 100 end_width = width + self.location.x if start_x is None: @@ -720,6 +828,11 @@ def swipe_until_text( return e(self.testui_driver, "uiautomator", f'textContains("{text}")') def send_keys(self, value, log=True): + """ + Send keys to element + :param value: value + :param log: Boolean + """ timeout = 10 # [seconds] start = time.time() if value is None: @@ -744,12 +857,23 @@ def send_keys(self, value, log=True): f"after {time.time() - start}s {logger.bcolors.ENDC}" ) - def clear(self) -> "Elements": + def clear(self) -> Elements: + """ + Clear the text of the element identified by the specified locator. + """ self.get_element().clear() return self def get_text(self): + """ + Retrieves the text of the element identified by the specified locator. + If the element is found within the timeout period (default 10 seconds), + its text is returned. If the element cannot be found within the timeout + period, an error message is printed and None is returned. + :return: The text of the element identified by the specified locator, or + None if the element cannot be found within the timeout period. + """ timeout = 10 # [seconds] start = time.time() @@ -772,6 +896,11 @@ def get_text(self): ) def get_value(self): + """ + Get the 'value' attribute of an element. + :return: The 'value' attribute of the element. + :raises: Expection if the element is not found within the timeout. + """ timeout = 10 # [seconds] start = time.time() @@ -794,6 +923,11 @@ def get_value(self): ) def get_name(self): + """ + Get the 'name' attribute of an element. + :return: The 'name' attribute of the element. + :raises: Expection if the element is not found within the timeout. + """ timeout = 10 # [seconds] start = time.time() @@ -817,6 +951,12 @@ def get_name(self): ) def get_attribute(self, att): + """ + Get the attribute value of an element. + + :return: The attribute value of the element. + :raises: Expection if the element is not found within the timeout. + """ timeout = 10 # [seconds] start = time.time() @@ -847,6 +987,14 @@ def press_and_compare( fps_reduction=1, keep_image_as="", ): + """ + Press and compare image + :param image: image + :param milliseconds: milliseconds + :param threshold: threshold + :param fps_reduction: fps_reduction + :param keep_image_as: keep_image_as + """ self.testui_driver.start_recording_screen() self.press_hold_for(milliseconds) @@ -876,6 +1024,10 @@ def press_and_compare( return self def collection_size(self): + """ + Returns the size of the collection + :return: The size of the collection + """ self.__is_collection = False index = self.index self.index = 0 @@ -887,6 +1039,13 @@ def collection_size(self): def find_by_attribute( self, attribute, value: str, timeout=10, case_sensitive=True ): + """ + Find element by attribute + :param attribute: attribute + :param value: value + :param timeout: timeout + :param case_sensitive: case_sensitive + """ start = time.time() self.wait_until_visible() self.__is_collection = True @@ -946,6 +1105,13 @@ def e(driver, locator_type: str, locator: str) -> Elements: def scroll_by_text(driver, text, element=None, exact_text=False) -> Elements: + """ + Scroll by text + :param driver: driver + :param text: text + :param element: element + :param exact_text: exact_text + """ if exact_text: method_text = "text" else: @@ -964,6 +1130,11 @@ def scroll_by_text(driver, text, element=None, exact_text=False) -> Elements: def scroll_by_resource_id(driver, res_id) -> Elements: + """ + Scroll by resource id + :param driver: driver + :param res_id: res_id + """ locator = ( "new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView" f'(new UiSelector().resourceId("{res_id}"));' @@ -975,6 +1146,11 @@ def scroll_by_resource_id(driver, res_id) -> Elements: class AndroidLocator: @classmethod def scroll(cls, method: str, scrollable_element=None): + """ + Generate locator to scroll element into view + :param method: method + :param scrollable_element: scrollable_element + """ if scrollable_element is None: scrollable_element = "scrollable(true)" @@ -984,25 +1160,51 @@ def scroll(cls, method: str, scrollable_element=None): ) @classmethod - def text(cls, text: str): + def text(cls, text: str) -> str: + """ + Generate locator to find element by text + :param text: text + """ return f'text("{text}")' @classmethod - def text_contains(cls, text: str): + def text_contains(cls, text: str) -> str: + """ + Generate locator to find element that contains the provided text + :param text: text + """ return f'textContains("{text}")' @classmethod - def id_match(cls, text: str): + def id_match(cls, text: str) -> str: + """ + Generate locator to find element by id + :param text: id + """ return f'resourceIdMatches("{text}")' @classmethod - def class_name(cls, text: str): + def class_name(cls, text: str) -> str: + """ + Generate locator to find element by class name + :param text: class name + """ return f'className("{text}")' @classmethod - def parent(cls, parent_method, child_method): + def parent(cls, parent_method, child_method) -> str: + """ + Generate locator to find element by parent and child + :param parent_method: parent_method + :param child_method: child_method + """ return f"fromParent({parent_method}).{child_method}" @classmethod - def child(cls, parent_method, child_method): + def child(cls, parent_method, child_method) -> str: + """ + Generate locator to find cield element by child and parent methods + :param parent_method: parent_method + :param child_method: child_method + """ return f"childSelector({child_method}).{parent_method}" diff --git a/testui/support/appium_driver.py b/testui/support/appium_driver.py index 241f345..a539d66 100644 --- a/testui/support/appium_driver.py +++ b/testui/support/appium_driver.py @@ -24,6 +24,10 @@ class NewDriver: + """ + Class for creating appium driver + """ + __configuration = Configuration() def __init__(self): @@ -61,39 +65,83 @@ def __init__(self): self.__chrome_options = {} def set_logger(self, logger_name: str or None = "pytest"): - """Possible loggers str: behave, pytest, None""" + """ + Set logger + Possible loggers str: behave, pytest, None + :param logger_name: logger name + """ self.logger_name = logger_name return self def set_appium_log_file(self, file="appium-stdout.log"): + """ + Set path to appium log file + :param file: file name + :return: self + """ self.__appium_log_file = file return self def set_browser(self, browser: str) -> "NewDriver": + """ + Set browser + :param browser: browser name + :return: self + """ self.__browser_name = browser return self def set_remote_url(self, url): + """ + Set remote url + :param url: url + :return: self + """ self.__remote_url = url return self def set_soft_assert(self, soft_assert: bool): + """ + Set soft assert + :param soft_assert: True or False + :return: self + """ self.soft_assert = soft_assert return self def set_appium_port(self, port: int): + """ + Set appium port + :param port: port + :return: self + """ self.appium_port = port return self def set_full_reset(self, full_reset: bool): + """ + Set full reset + :param full_reset: True or False + :return: self + """ self.__full_reset = full_reset return self def set_appium_url(self, appium_url: str): + """ + Set appium url + :param appium_url: appium url + :return: self + """ self.__appium_url = appium_url return self def set_extra_caps(self, caps=None): + """ + Set extra capabilities + :param caps: capabilities + :return: self + """ if caps is None: caps = {} for cap in caps: @@ -101,16 +149,26 @@ def set_extra_caps(self, caps=None): return self def set_app_path(self, path: str): + """ + Set app path + :param path: path to app + :return: self + """ self.__app_path = path if os.path.isabs(self.__app_path): return self - else: - root_dir = self.configuration.screenshot_path - self.__app_path = os.path.join(root_dir, path) - logger.log(self.__app_path) - return self + + root_dir = self.configuration.screenshot_path + self.__app_path = os.path.join(root_dir, path) + logger.log(self.__app_path) + return self def set_udid(self, udid: str): + """ + Set udid + :param udid: udid + :return: self + """ self.udid = udid return self @@ -119,45 +177,76 @@ def set_bundle_id(self, bundle_id: str): return self def set_udid_if_exists(self, udid: str, number=None): + """ + Set udid if exists + :param udid: udid + :param number: number of device + :return: self + """ self.udid = check_device_exist(udid) if self.udid is None: self.udid = get_device_udid(number) return self def set_connected_device(self, number: int): + """ + Set connected device + :param number: number of device + :return: self + """ self.udid = get_device_udid(number) return self def set_device_name(self, device_name: str): + """ + Set device name + :param device_name: device name + :return: self + """ self.device_name = device_name return self def set_version(self, version: str): + """ + Set version + :param version: version + :return: self + """ self.__version = version return self def set_grant_permissions(self, permissions: bool): + """ + Set grant permissions + :param permissions: True or False + :return: self + """ # pylint: disable=unused-private-member self.__auto_accept_alerts = permissions return self def set_app_package_activity(self, app_package: str, app_activity: str): + """Set app package and activity""" self.__app_package = app_package self.__app_activity = app_activity return self def get_driver(self) -> WebDriver: + """Get driver""" driver = self.__driver return driver @property def configuration(self) -> Configuration: + """Get configuration""" return self.__configuration def get_testui_driver(self) -> TestUIDriver: + """Get TestUIDriver""" return TestUIDriver(self) def set_chrome_driver(self, version="") -> "NewDriver": + """Set chrome driver""" mobile_version = version if version == "": if self.udid is None: @@ -172,23 +261,30 @@ def set_chrome_driver(self, version="") -> "NewDriver": return self def set_screenshot_path(self, screenshot_path: str): + """Set screenshot path""" self.__configuration.screenshot_path = screenshot_path return self def set_save_screenshot_on_fail(self, save_screenshot_on_fail: bool): + """Set save screenshot on fail""" self.__configuration.save_full_stacktrace = save_screenshot_on_fail return self def set_save_full_stacktrace(self, save_full_stacktrace: bool): + """Set save full stacktrace""" self.__configuration.save_full_stacktrace = save_full_stacktrace return self - # Available platforms: Android, iOS def set_platform(self, platform): + """ + Set platform + Available platforms: Android, iOS + """ self.__platform_name = platform return self def __set_common_caps(self): + """Set common capabilities""" self.__desired_capabilities["adbExecTimeout"] = 30000 self.__desired_capabilities["platformName"] = self.__platform_name self.__desired_capabilities["automationName"] = self.__automation_name @@ -203,6 +299,7 @@ def __set_common_caps(self): self.__desired_capabilities["udid"] = self.udid def __set_android_caps(self): + """Set Android capabilities""" if self.__automation_name is None: self.__automation_name = "UiAutomator2" self.__desired_capabilities["chromeOptions"] = {"w3c": False} @@ -229,9 +326,14 @@ def __set_android_caps(self): self.__desired_capabilities["androidInstallPath"] = self.__app_path def __set_ios_caps(self): + """Sets the iOS capabilities""" if self.__automation_name is None: self.__automation_name = "XCUITest" - if self.__app_path is None and self.__bundle_id is None and self.__app_package is None: + if ( + self.__app_path is None + and self.__bundle_id is None + and self.__app_package is None + ): self.__desired_capabilities["browserName"] = "safari" self.browser = True if self.__app_path is not None: @@ -242,9 +344,14 @@ def __set_ios_caps(self): self.__desired_capabilities["platformVersion"] = "15.5" def __set_selenium_caps(self): + """Sets the selenium capabilities""" self.__desired_capabilities["browserName"] = self.__browser_name def set_appium_driver(self) -> TestUIDriver: + """ + Sets the appium driver + :return: TestUIDriver + """ if self.__platform_name.lower() == "android": self.__set_android_caps() else: @@ -265,6 +372,12 @@ def set_selenium_driver( chrome_options: ChromeOptions or None = None, firefox_options: FirefoxOptions or None = None, ) -> TestUIDriver: + """ + Sets the selenium driver + :param chrome_options: Chrome options + :param firefox_options: Firefox options + :return: TestUIDriver + """ self.__set_selenium_caps() self.__driver = start_selenium_driver( self.__desired_capabilities, @@ -278,20 +391,35 @@ def set_selenium_driver( return self.get_testui_driver() def set_driver(self, driver) -> TestUIDriver: + """ + Sets the driver + :param driver: Driver + :return: TestUIDriver + """ self.__set_selenium_caps() self.__driver = driver return self.get_testui_driver() def start_driver(desired_caps, url, debug, port, udid, log_file): + """ + Starts the appium driver + :param desired_caps: Desired capabilities + :param url: Appium url + :param debug: Debug mode + :param port: Appium port + :param udid: Device udid + :param log_file: Appium log file + :return: Appium driver + """ lock = threading.Lock() lock.acquire() - logger.log("setting capabilities: " + desired_caps.__str__()) + logger.log("setting capabilities: " + str(desired_caps)) logger.log("starting appium driver...") process = None - if desired_caps["platformName"].lower().__contains__("android"): + if "android" in desired_caps["platformName"].lower(): url, desired_caps, process, file = __local_run( url, desired_caps, port, udid, log_file ) @@ -303,6 +431,7 @@ def start_driver(desired_caps, url, debug, port, udid, log_file): for _ in range(2): try: import warnings + with warnings.catch_warnings(): # To suppress a warning from an issue on selenium side warnings.filterwarnings("ignore", category=DeprecationWarning) @@ -325,16 +454,25 @@ def start_selenium_driver( chrome_options: ChromeOptions or None = None, firefox_options: FirefoxOptions or None = None, ) -> WebDriver: - """Starts a new local session of the specified browser.""" + """ + Starts a new local session of the specified browser + :param desired_caps: Desired capabilities + :param url: Remote url + :param debug: Debug mode + :param browser: Browser name + :param chrome_options: Chrome options + :param firefox_options: Firefox options + :return: WebDriver + """ options = chrome_options if firefox_options is not None: options = firefox_options if options is not None: - logger.log(f"setting options: {options.to_capabilities().__str__()}") + logger.log(f"setting options: {str(options.to_capabilities())}") - logger.log(f"setting capabilities: {desired_caps.__str__()}") + logger.log(f"setting capabilities: {str(desired_caps)}") logger.log(f"starting selenium {browser.lower()} driver...") err = None @@ -347,19 +485,22 @@ def start_selenium_driver( options = ChromeOptions() for key, value in desired_caps.items(): options.set_capability(key, value) - logger.log(f"final options: {options.to_capabilities().__str__()}") + logger.log(f"final options: {str(options.to_capabilities())}") driver = webdriver.Remote(command_executor=url, options=options) else: if browser.lower() == "chrome": + if options is None: + options = ChromeOptions() for key, value in desired_caps.items(): options.set_capability(key, value) + logger.log(f"final options: {str(options.to_capabilities())}") driver = webdriver.Chrome(options=options) elif browser.lower() == "firefox": try: geckodriver_autoinstaller.install() except Exception as error: logger.log_warn( - "Could not retrieve geckodriver: " + error.__str__() + "Could not retrieve geckodriver: " + str(error) ) if "marionette" not in desired_caps: desired_caps["marionette"] = True @@ -368,11 +509,11 @@ def start_selenium_driver( options = FirefoxOptions() for key, value in desired_caps.items(): options.set_capability(key, value) - logger.log(f"final options: {options.to_capabilities().__str__()}") - - driver = webdriver.Firefox( - options=options + logger.log( + f"final options: {str(options.to_capabilities())}" ) + + driver = webdriver.Firefox(options=options) elif browser.lower() == "safari": driver = webdriver.Safari(desired_capabilities=desired_caps) elif browser.lower() == "edge": @@ -395,6 +536,15 @@ def start_selenium_driver( def __local_run(url, desired_caps, use_port, udid, log_file): + """ + Starts appium server locally + :param url: url to connect to + :param desired_caps: desired capabilities + :param use_port: port to use + :param udid: device udid + :param log_file: log file + :return: url, desired capabilities, appium process, log file + """ if url is None: port = use_port bport = use_port + 1 @@ -409,7 +559,7 @@ def __local_run(url, desired_caps, use_port, udid, log_file): os.getenv("PYTEST_XDIST_WORKER").split("w")[1] ) bport += int(os.getenv("PYTEST_XDIST_WORKER").split("w")[1]) * 2 - logger.log(f"running: appium -p {port.__str__()}") + logger.log(f"running: appium -p {str(port)}") if udid is None: desired_caps = __set_android_device(desired_caps, device) logger.log(f'setting device for automation: {desired_caps["udid"]}') @@ -422,7 +572,7 @@ def __local_run(url, desired_caps, use_port, udid, log_file): file_path = os.path.join(log_dir, log_file) with open(file_path, "wb") as out: process = subprocess.Popen( - ["appium", "-p", port.__str__()], + ["appium", "-p", str(port)], stdout=out, stderr=subprocess.STDOUT, ) @@ -431,24 +581,31 @@ def __local_run(url, desired_caps, use_port, udid, log_file): sleep(0.5) out = open(file_path) text = out.read() - if text.__contains__("already be in use") or text.__contains__( - "listener started" - ): + if "already be in use" in text or "listener started" in text: out.close() break out.close() # Check Appium Version result = subprocess.run(["appium", "-v"], stdout=subprocess.PIPE).stdout - url = f"http://localhost:{port.__str__()}/wd/hub" + url = f"http://localhost:{str(port)}/wd/hub" if result.decode('utf-8').startswith("2."): # for Appium version > 2.0.0 - url = f"http://localhost:{port.__str__()}" + url = f"http://localhost:{str(port)}" return url, desired_caps, process, file_path return url, desired_caps, None, None def __local_run_ios(url, desired_caps, use_port, udid, log_file): + """ + Starts appium server for iOS + :param url: url to connect to + :param desired_caps: desired capabilities + :param use_port: port to use + :param udid: device udid + :param log_file: log file name + :return: url, desired capabilities, process + """ process = None if url is None: port = use_port + 100 @@ -462,7 +619,7 @@ def __local_run_ios(url, desired_caps, use_port, udid, log_file): desired_caps["systemPort"] = 8300 + int( os.getenv("PYTEST_XDIST_WORKER").split("w")[1] ) - logger.log(f"running: appium -p {port.__str__()}") + logger.log(f"running: appium -p {str(port)}") log_dir = os.path.join("./logs", "appium_logs") Path(log_dir).mkdir(parents=True, exist_ok=True) file_path: str @@ -472,7 +629,7 @@ def __local_run_ios(url, desired_caps, use_port, udid, log_file): file_path = os.path.join(log_dir, log_file) with open(file_path, "wb") as out: process = subprocess.Popen( - ["appium", "-p", port.__str__()], + ["appium", "-p", str(port)], stdout=out, stderr=subprocess.STDOUT, ) @@ -483,24 +640,28 @@ def __local_run_ios(url, desired_caps, use_port, udid, log_file): sleep(0.5) out = open(file_path) text = out.read() - if text.__contains__("already be in use") or text.__contains__( - "listener started" - ): + if "already be in use" in text or "listener started" in text: out.close() break out.close() # Check Appium Version - url = f"http://localhost:{port.__str__()}/wd/hub" + url = f"http://localhost:{str(port)}/wd/hub" result = subprocess.run(["appium", "-v"], stdout=subprocess.PIPE).stdout if result.decode('utf-8').startswith("2."): # for Appium version > 2.0.0 - url = f"http://localhost:{port.__str__()}" + url = f"http://localhost:{str(port)}" return url, desired_caps, file_path return url, desired_caps, process def __set_android_device(desired_caps, number: int): + """ + Set android device by index + :param desired_caps: desired capabilities + :param number: device index + :return: desired capabilities + """ desired_caps["udid"] = get_device_udid(number) return desired_caps @@ -513,6 +674,11 @@ def __set_ios_device(desired_caps, number: int): def get_device_udid(number: int): + """ + Get device udid by index + :param number: device index + :return: device udid + """ client = AdbClient(host="127.0.0.1", port=5037) devices = client.devices() if len(devices) == 0: @@ -520,18 +686,23 @@ def get_device_udid(number: int): if len(devices) > number: logger.log(f"Setting device: {devices[number].get_serial_no()}") return devices[number].get_serial_no() - else: - new_number = number - (number // len(devices)) * len(devices) - logger.log_warn( - f"You choose device number {number + 1} but there are only " - f"{len(devices)} connected. " - f"Will use device number {new_number + 1} instead", - jump_line=True, - ) - return devices[new_number].get_serial_no() + + new_number = number - (number // len(devices)) * len(devices) + logger.log_warn( + f"You choose device number {number + 1} but there are only " + f"{len(devices)} connected. " + f"Will use device number {new_number + 1} instead", + jump_line=True, + ) + return devices[new_number].get_serial_no() def check_device_exist(udid): + """ + Check if device exist + :param udid: device udid + :return: device udid if exist, None otherwise + """ client = AdbClient(host="127.0.0.1", port=5037) devices = client.devices() for device in devices: @@ -541,6 +712,11 @@ def check_device_exist(udid): def check_chrome_version(udid): + """ + Check chrome version on device + :param udid: device udid + :return: chrome version if exist, None otherwise + """ output = subprocess.Popen( [ "adb", @@ -557,15 +733,20 @@ def check_chrome_version(udid): stdout=subprocess.PIPE, ) response = output.communicate() - if response.__str__().__contains__("versionName="): + if "versionName=" in str(response): return get_chrome_version( - response.__str__().split("versionName=")[1].split(".")[0] + str(response).split("versionName=")[1].split(".")[0] ) return None def __quit_driver(driver, debug): + """ + Quit driver + :param driver: driver + :param debug: debug mode + """ try: driver.quit() except Exception as err: diff --git a/testui/support/configuration.py b/testui/support/configuration.py index f223b19..7544758 100644 --- a/testui/support/configuration.py +++ b/testui/support/configuration.py @@ -1,28 +1,56 @@ class Configuration: + """ + Configuration class for TestUI + """ + __screenshot_path: str = "" __save_screenshot_on_fail: bool = True __save_full_stacktrace: bool = True @property def screenshot_path(self) -> str: + """ + Path to save screenshots + :return: String + """ return self.__screenshot_path @screenshot_path.setter def screenshot_path(self, path: str) -> None: + """ + Path to save screenshots + :param path: String + """ self.__screenshot_path = path @property def save_screenshot_on_fail(self) -> bool: + """ + Save screenshot on fail + :return: Boolean + """ return self.__save_screenshot_on_fail @save_screenshot_on_fail.setter def save_screenshot_on_fail(self, value: bool) -> None: + """ + Save screenshot on fail + :param value: Boolean + """ self.__save_screenshot_on_fail = value @property def save_full_stacktrace(self) -> bool: + """ + Save full stacktrace on fail + :return: Boolean + """ return self.__save_full_stacktrace @save_full_stacktrace.setter def save_full_stacktrace(self, value: bool) -> None: + """ + Save full stacktrace on fail + :param value: Boolean + """ self.__save_full_stacktrace = value diff --git a/testui/support/helpers.py b/testui/support/helpers.py index 7f3c102..48ce62d 100644 --- a/testui/support/helpers.py +++ b/testui/support/helpers.py @@ -5,11 +5,16 @@ def error_with_traceback(exception): + """ + This function is used to get the full stacktrace of an exception + :param exception: Exception + :return: String + """ root_dir = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) line: str for line in traceback.extract_stack().format(): - if root_dir in line and not "traceback.extract_stack()" in line: + if root_dir in line and "traceback.extract_stack()" not in line: exception += logger.bcolors.FAIL + line + logger.bcolors.ENDC return exception diff --git a/testui/support/logger.py b/testui/support/logger.py index a27f159..5206453 100644 --- a/testui/support/logger.py +++ b/testui/support/logger.py @@ -8,6 +8,7 @@ class bcolors: """ Colors for console output """ + HEADER = "\033[95m" OKBLUE = "\033[94m" OKGREEN = "\033[92m" diff --git a/testui/support/parallel.py b/testui/support/parallel.py index 0b0f0b7..a996563 100644 --- a/testui/support/parallel.py +++ b/testui/support/parallel.py @@ -143,10 +143,10 @@ def get_total_number_of_cases(args): check=False, ) response = output.stdout - string_response = response.__str__() - if string_response.__contains__(" / "): + string_response = str(response) + if " / " in string_response: for cases in string_response.split(" / "): - if cases.__contains__(" selected"): + if " selected" in cases: number = cases.split(" selected")[0] number_of_cases += int(number) if args.single_thread_marker is not None: @@ -161,10 +161,10 @@ def get_total_number_of_cases(args): check=False, ) response = output.stdout - string_response = response.__str__() - if string_response.__contains__(" / "): + string_response = str(response) + if " / " in string_response: for cases in string_response.split(" / "): - if cases.__contains__(" selected"): + if " selected" in cases: number = cases.split(" selected")[0] number_of_cases += int(number) return number_of_cases @@ -182,6 +182,7 @@ def __seconds_to_minutes(time_seconds): s = f"0{seconds}" if seconds < 10 else f"{seconds}" return f"{ms}:{s} ({time_seconds}s)" + def __arg_parser(): """ Will parse the arguments that will be used to execute the test cases. @@ -363,19 +364,19 @@ def __start_run_id(args, test_run_name): time.sleep(0.1) out = open("testrail_id_file.txt") text = out.read() - if text.__contains__("New testrun created") or i > 50: + if "New testrun created" in text or i > 50: out.close() process.terminate() process.wait() os.remove("testrail_id_file.txt") - if text.__contains__("ID="): + if "ID=" in text: id_test = text.split("ID=")[1] - if text.split("ID=")[1].__contains__("\n"): + if "\n" in text.split("ID=")[1]: id_test = text.split("ID=")[1].split("\n")[0] logger.log_info(f"Test run: {id_test}") return id_test raise Exception("Failed to create Test Run") - if text.__contains__("Failed to create testrun"): + if "Failed to create testrun" in text: out.close() process.send_signal(signal=2) process.terminate() diff --git a/testui/support/testui_driver.py b/testui/support/testui_driver.py index ca1b7ff..6813838 100644 --- a/testui/support/testui_driver.py +++ b/testui/support/testui_driver.py @@ -22,6 +22,7 @@ class TestUIDriver: This class is the main class for the TestUI framework. It is used to initialize the driver and to perform actions on the device. """ + __test__ = False def __init__(self, driver): @@ -93,19 +94,21 @@ def e(self, locator_type, locator): def execute(self, driver_command, params=None): """ - This method is meant for Appium Drivers Only + This method is meant for Appium Drivers only. Will execute a command + in the current driver. :param driver_command: :param params: - :return: + :return: TestUIDriver """ - self.get_driver().execute(driver_command, params) + self.get_driver.execute(driver_command, params) + return self def remove_log_file(self, when_no_errors=True): """ Removes appium log file. If when_no_errors is False, it will always remove errors, if True then just when there are errors. :param when_no_errors: - :return: + :return: TestUIDriver """ if self.file_name is not None: try: @@ -115,35 +118,38 @@ def remove_log_file(self, when_no_errors=True): os.remove(self.file_name) except FileNotFoundError: logger.log_debug("Log file already removed") + return self - def touch_actions(self): + def touch_actions(self) -> TouchAction: """ Will return a TouchAction object for the current driver. This is meant for Appium Drivers only. - :return: + :return: TouchAction """ - return TouchAction(self.get_driver()) + return TouchAction(self.get_driver) - def actions(self): + def actions(self) -> ActionChains: """ Will return an ActionChains object for the current driver. + :return: ActionChains """ - return ActionChains(self.get_driver()) + return ActionChains(self.get_driver) def open_notifications(self): """ - Will open the notifications panel on the device. This method is meant + Will open the notifications panel on the device. This method is meant for Appium Drivers only - :return: + :return: TestUIDriver """ - self.get_driver().open_notifications() + self.get_driver.open_notifications() + return self def back(self): """ Will perform a back action on the device in browser history. - :return: + :return: TestUIDriver """ - self.get_driver().back() + self.get_driver.back() return self def quit(self, stop_server=True): @@ -152,27 +158,28 @@ def quit(self, stop_server=True): :param stop_server: :return: """ - self.get_driver().quit() + self.get_driver.quit() if self.__process is not None and stop_server: self.__process.kill() def navigate_to(self, url): """ - Will navigate to a specific url. + Will navigate to a specific url. :param url: - :return: + :return: TestUIDriver """ - self.get_driver().get(url) + self.get_driver.get(url) logger.log(f"{self.device_name}: Navigating to: {url}") + return self - def execute_script(self, driver_command, args: None): + def execute_script(self, driver_command, args: None) -> dict: """ Will execute a JavaScript script in the current window/frame. :param driver_command: :param args: - :return: + :return: dict of the result of executed script """ - self.get_driver().execute_script(driver_command, args) + return self.get_driver.execute_script(driver_command, args) @property def switch_to(self): @@ -181,25 +188,26 @@ def switch_to(self): This method is meant for Appium Drivers only. :return: """ - return self.get_driver().switch_to + return self.get_driver.switch_to def set_network_connection(self, number): """ - Will set the network connection type based on the network connection + Will set the network connection type based on the network connection number. This method is meant for Appium Drivers Only :param number: - :return: + :return: TestUIDriver """ - self.get_driver().set_network_connection(number) + self.get_driver.set_network_connection(number) + return self @property - def network_connection(self): + def network_connection(self) -> int: """ - Get the current network connection type. This method is meant for + Get the current network connection type. This method is meant for Appium Drivers only. :return: """ - return self.get_driver().network_connection + return self.get_driver.network_connection def find_image_match( self, @@ -208,7 +216,7 @@ def find_image_match( assertion=False, not_found=False, image_match="", - ): + ) -> bool: """ Will find an image match based on the comparison type and threshold within the current screen. @@ -228,7 +236,11 @@ def find_image_match( comparison ) found, p = ImageRecognition( - image_path, comparison, threshold, self.device_name, self.configuration.screenshot_path + image_path, + comparison, + threshold, + self.device_name, + self.configuration.screenshot_path, ).compare(image_match) if assertion and not found and not not_found: exception = self.new_error_message( @@ -243,27 +255,30 @@ def find_image_match( def click_by_image(self, image: str, threshold=0.9, webview=False): """ - Will click on an element based on the image provided if it can be found + Will click on an element based on the image provided if it can be found within the current screen. :param image: :param threshold: :param webview: - :return: + :return: TestUIDriver """ now = datetime.now() current_time = now.strftime("%Y-%m-%d%H%M%S") image_name = f"{self.device_udid}{current_time}.png" im_path = self.save_screenshot(image_name) - x, y = get_point_match( - im_path, image, threshold, self.device_name - ) + x, y = get_point_match(im_path, image, threshold, self.device_name) ta = TouchAction(self.__appium_driver) if webview: y = y + 120 ta.tap(x=x, y=y).perform() - logger.log(f"{self.device_name}: element with image {image} clicked on point ({x},{y})") + logger.log( + f"{self.device_name}: element with image {image}" + "clicked on point ({x},{y})" + ) self.__delete_screenshot(im_path) + return self + def get_dimensions(self): """ Will return the dimensions of the current screen. @@ -282,22 +297,23 @@ def get_dimensions(self): def click(self, x, y): """ - Will execute a touch action on the current screen based on the x and y + Will execute a touch action on the current screen based on the x and y coordinates. :param x: :param y: - :return: + :return: TestUIDriver """ ta = TouchAction(self.__appium_driver) ta.tap(x=x, y=y).perform() logger.log(f'Clicked over "x={x}: y={y}"') + return self - def save_screenshot(self, image_name=""): + def save_screenshot(self, image_name="") -> str: """ Will save a screenshot of the current screen. If no image_name is provided, it will generate a name based on the current time. :param image_name: - :return: + :return: str of the path where the screenshot was saved. """ config = self.__configuration @@ -315,7 +331,7 @@ def save_screenshot(self, image_name=""): final_path = path.join(log_dir, image_name) - self.get_driver().save_screenshot(final_path) + self.get_driver.save_screenshot(final_path) logger.log_debug( self.new_error_message(f'Screenshot saved in "{final_path}"') @@ -332,6 +348,7 @@ def __delete_screenshot(cls, image_name): """ os.remove(image_name) + @property def get_driver(self) -> WebDriver: """ Will return the current driver. @@ -367,7 +384,7 @@ def raise_errors(self, remove_log_file=False): composed_error = "\n" i = 1 for error in self.errors: - composed_error += f"Error {i.__str__()}: {error}\n" + composed_error += f"Error {str(i)}: {error}\n" i += 1 self.errors = [] raise Exception(composed_error) @@ -379,67 +396,74 @@ def get_clipboard_text(self) -> str: Will return the current clipboard text. :return: The current clipboard text. """ - return self.get_driver().get_clipboard_text() + return self.get_driver.get_clipboard_text() def set_power_capacity(self, capacity: int): """ Will set the power capacity of the current device. :param capacity: The power capacity to set. - :return: + :return: TestUIDriver """ try: - self.get_driver().set_power_capacity(capacity) + self.get_driver.set_power_capacity(capacity) except WebDriverException as wd_exception: exception = self.new_error_message( "powerCapacity method is only available for emulators" ) logger.log_error(error_with_traceback(exception)) raise Exception(exception) from wd_exception + return self def background_app(self, seconds): """ Will background the current app for the provided seconds. :param seconds: The seconds to background the app. - :return: + :return: TestUIDriver """ - self.get_driver().background_app(seconds) + self.get_driver.background_app(seconds) + return self def remove_app(self, app_id): """ Will remove the provided app from the current device. :param app_id: The app id to remove. - :return: + :return: TestUIDriver """ - self.get_driver().remove_app(app_id) + self.get_driver.remove_app(app_id) + return self def install_app(self, app_id): """ Will install the provided app in the current device. :param app_id: The app id to install. - :return: + :return: TestUIDriver """ - self.get_driver().install_app(app_id) + self.get_driver.install_app(app_id) + return self def start_recording_screen(self): """ Start recording the screen on current device. - :return: + :return: TestUIDriver """ - self.get_driver().start_recording_screen() + self.get_driver.start_recording_screen() + return self def stop_recording_screen(self, file_name="testui-video.mp4"): """ Stop recording the screen and save the video in the root directory. :param file_name: - :return: + :return: TestUIDriver """ - file = self.get_driver().stop_recording_screen() + file = self.get_driver.stop_recording_screen() decoded_string = base64.b64decode(file) log_dir = self.configuration.screenshot_path logger.log(f"Recording stopped in {os.path.join(log_dir, file_name)}") with open(os.path.join(log_dir, file_name), "wb") as wfile: wfile.write(decoded_string) + return self + def stop_recording_and_compare( self, comparison, @@ -465,7 +489,9 @@ def stop_recording_and_compare( video_name = f"{self.device_udid}{current_time}.mp4" self.stop_recording_screen(os.path.join(log_dir, video_name)) found = ImageRecognition( - video_name, comparison, threshold, + video_name, + comparison, + threshold, device_name=self.device_name, path=log_dir ).compare_video(keep_image_as, frame_rate_reduction=fps_reduction) @@ -497,7 +523,7 @@ def new_error_message(self, message) -> str: """ Create new error message with device name included :param message: - :return: The error message + :return: str containing the error message """ return f"{self.device_name}: {message}" @@ -505,6 +531,7 @@ def hide_keyboard(self): """ Hide the keyboard if it is showing. This method is meant for Appium Drivers Only. - :return: + :return: TestUIDriver """ - self.get_driver().hide_keyboard() + self.get_driver.hide_keyboard() + return self diff --git a/testui/support/testui_images.py b/testui/support/testui_images.py index de4e415..b94ea01 100644 --- a/testui/support/testui_images.py +++ b/testui/support/testui_images.py @@ -20,7 +20,7 @@ def compare_video_image( image_match, frame_rate_reduction=1, max_scale=2.0, - path="" + path="", ): """ Compare an image to a video and return the percentage of similarity @@ -41,11 +41,14 @@ def compare_video_image( matching_list = [] root_dir = path - logger.log_debug(f'root directory: {root_dir}') + logger.log_debug(f"root directory: {root_dir}") cap = cv2.VideoCapture(os.path.join(root_dir, video)) template = cv2.imread(os.path.join(root_dir, comparison)) if template is None: - logger.log_warn(f'trying to compare with an image that doesn\'t exist! {os.path.join(root_dir, comparison)}') + logger.log_warn( + "trying to compare with an image that doesn't exist!" + f"{os.path.join(root_dir, comparison)}" + ) return False, 0.0 i = 0 percentage = 0.0 @@ -55,7 +58,14 @@ def compare_video_image( if ret and i % frame_rate_reduction == 0: logger.log(f"frame evaluation = {i}") found, percentage = __compare( - frame, template, threshold, image_match, root_dir, max_scale, 0.1, 50 + frame, + template, + threshold, + image_match, + root_dir, + max_scale, + 0.1, + 50, ) if found: cap.release() @@ -77,7 +87,7 @@ def __compare( root_dir: str, max_scale: float, min_scale=0.1, - divisions=25 + divisions=25, ): """ Compare a template image to a larger image and return the percentage of @@ -97,7 +107,7 @@ def __compare( global found_image global matched global matching_list - maxVal = 0.0 + max_val = 0.0 for scale in np.linspace(min_scale, max_scale, divisions)[::-1]: # resize the image according to the scale, and keep track of the ratio # of the resizing. @@ -108,40 +118,47 @@ def __compare( if resized.shape[0] < tH or resized.shape[1] < tW: break result = cv2.matchTemplate(resized, template, cv2.TM_CCOEFF_NORMED) - (_, maxVal, _, maxLoc) = cv2.minMaxLoc(result) + (_, max_val, _, max_loc) = cv2.minMaxLoc(result) # if we have found a new maximum correlation value, then update the # bookkeeping variable - if found is None or maxVal > found[0]: + if found is None or max_val > found[0]: lock = threading.Lock() lock.acquire() if found_image: lock.release() return True, matched - matching_list.append(maxVal) + matching_list.append(max_val) lock.release() - found = (maxVal, maxLoc, r) - if maxVal > threshold: + found = (max_val, max_loc, r) + if max_val > threshold: if image_match != "" and found is not None: # unpack the bookkeeping variable and compute the (x, y) # coordinates of the bounding box based on the resized ratio - (_, maxLoc, r) = found - (startX, startY) = (int(maxLoc[0] * r), int(maxLoc[1] * r)) - (endX, endY) = ( - int((maxLoc[0] + tW) * r), - int((maxLoc[1] + tH) * r), + (_, max_loc, r) = found + (start_x, start_y) = ( + int(max_loc[0] * r), + int(max_loc[1] * r), + ) + (end_x, end_y) = ( + int((max_loc[0] + tW) * r), + int((max_loc[1] + tH) * r), ) # draw a bounding box around the detected result and display # the image cv2.rectangle( - image, (startX, startY), (endX, endY), (0, 0, 255), 2 + image, + (start_x, start_y), + (end_x, end_y), + (0, 0, 255), + 2, ) cv2.imwrite(os.path.join(root_dir, image_match), image) logger.log(os.path.join(root_dir, image_match)) lock.acquire() found_image = True lock.release() - matched = maxVal - return True, maxVal + matched = max_val + return True, max_val matched = max(matching_list) return False, matched @@ -153,7 +170,7 @@ def compare_images( image_match="", max_scale=2.0, min_scale=0.3, - path="" + path="", ): """ Compare two images and return a boolean if they are similar or not @@ -303,21 +320,21 @@ def get_point_match( if resized.shape[0] < tH or resized.shape[1] < tW: break result = cv2.matchTemplate(resized, template, cv2.TM_CCOEFF_NORMED) - (_, maxVal, _, maxLoc) = cv2.minMaxLoc(result) + (_, max_val, _, max_loc) = cv2.minMaxLoc(result) # if we have found a new maximum correlation value, then update the # bookkeeping variable - if found is None or maxVal > found[0]: - found = (maxVal, maxLoc, r) - if maxVal > threshold: + if found is None or max_val > found[0]: + found = (max_val, max_loc, r) + if max_val > threshold: break # unpack the bookkeeping variable and compute the (x, y) coordinates # of the bounding box based on the resized ratio - (_, maxLoc, r) = found - (startX, startY) = (int(maxLoc[0] * r), int(maxLoc[1] * r)) - (endX, endY) = (int((maxLoc[0] + tW) * r), int((maxLoc[1] + tH) * r)) + (_, max_loc, r) = found + (start_x, start_y) = (int(max_loc[0] * r), int(max_loc[1] * r)) + (end_x, end_y) = (int((max_loc[0] + tW) * r), int((max_loc[1] + tH) * r)) - return startX + (endX - startX) // 2, startY + (endY - startY) // 2 + return start_x + (end_x - start_x) // 2, start_y + (end_y - start_y) // 2 def draw_match( @@ -352,7 +369,6 @@ def draw_match( cv2.imwrite("something.png", suh) - def size(image_path): """ Gets the size of an image. @@ -370,7 +386,12 @@ class ImageRecognition: """ def __init__( - self, original: str, comparison="", threshold=0.9, device_name="Device", path="./logs" + self, + original: str, + comparison="", + threshold=0.9, + device_name="Device", + path="./logs", ): self.__original = original self.__comparison = comparison @@ -393,7 +414,7 @@ def compare(self, image_match="", max_scale=2.0, min_scale=0.3): image_match, max_scale, min_scale, - self.__path + self.__path, ) if self.__threshold > p1: logger.log_debug( @@ -428,7 +449,7 @@ def compare_video( image_match, frame_rate_reduction, max_scale, - self.__path + self.__path, ) if found: logger.log_debug( @@ -514,16 +535,13 @@ def crop_original_image( x = center_x - width // 2 if x < 0: x *= -1 - img_2 = img[y:y + height, x:x + width] + img_2 = img[y : y + height, x : x + width] cv2.imwrite(image_name, img_2) return self class Dimensions: - """ - Class to store the dimensions of an image - """ - + """Class to store the dimensions of an image""" def __init__(self, x, y): self.x = x self.y = y