diff --git a/testui/support/logger.py b/testui/support/logger.py index c45948b..cc18fce 100644 --- a/testui/support/logger.py +++ b/testui/support/logger.py @@ -4,6 +4,9 @@ class bcolors: + """ + Colors for console output + """ HEADER = "\033[95m" OKBLUE = "\033[94m" OKGREEN = "\033[92m" @@ -15,6 +18,11 @@ class bcolors: def log(message, jump_line=False): + """ + Log a message to the console and to the log file + :param message: String + :param jump_line: Boolean + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") new_line = "" @@ -27,6 +35,11 @@ def log(message, jump_line=False): def log_pass(message, jump_line=False): + """ + Log a message to the console and to the log file + :param message: String + :param jump_line: Boolean + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") new_line = "" @@ -41,6 +54,11 @@ def log_pass(message, jump_line=False): def log_error(message, use_date=False): + """ + Log an error message to the console and to the log file + :param message: String + :param use_date: Boolean + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") date = "" @@ -54,6 +72,10 @@ def log_error(message, use_date=False): def log_debug(message): + """ + Log a debug message to the console and to the log file + :param message: String + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") print(f"{bcolors.OKBLUE}[{current_time}] {message}{bcolors.ENDC}") @@ -63,6 +85,11 @@ def log_debug(message): def log_warn(message, jump_line=False): + """ + Log a warning message to the console and to the log file + :param message: String + :param jump_line: Boolean + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") new_line = "" @@ -78,6 +105,11 @@ def log_warn(message, jump_line=False): def log_test_name(message, jump_line=False): + """ + Log a test name message to the console and to the log file + :param message: String + :param jump_line: Boolean + """ log_info(message, jump_line) new_line = "" if jump_line: @@ -88,6 +120,11 @@ def log_test_name(message, jump_line=False): def log_info(message, jump_line=False): + """ + Log a info message to the console and to the log file + :param message: String + :param jump_line: Boolean + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d %H:%M:%S") new_line = "" @@ -103,6 +140,11 @@ def log_info(message, jump_line=False): def __file_log(log_file="stdout.log"): + """ + Get the path of the log file + :param log_file: String + :return: String + """ root_dir = ( os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -119,6 +161,11 @@ def __file_log(log_file="stdout.log"): def __file_tests(log_file="report_cases.txt"): + """ + Get the path of the log file + :param log_file: String + :return: String + """ root_dir = ( os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/testui/support/parallel.py b/testui/support/parallel.py index d076970..dd01d01 100644 --- a/testui/support/parallel.py +++ b/testui/support/parallel.py @@ -8,6 +8,10 @@ def parallel_testui(): + """ + This function is the main function of the parallel execution. + It will start the execution of the test cases in parallel. + """ remove_logs() args = __arg_parser() try: @@ -82,6 +86,9 @@ def parallel_testui(): def remove_logs(): + """ + Will remove the logs from the previous execution. + """ root_dir = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) @@ -110,6 +117,11 @@ def remove_logs(): def get_total_number_of_cases(args): + """ + Will get the total number of cases that will be executed. + :param args: The arguments that will be used to execute the test cases. + :return: The total number of cases that will be executed. + """ number_of_cases = 0 for marker in args.markers: output = subprocess.run( @@ -151,20 +163,22 @@ def get_total_number_of_cases(args): def __seconds_to_minutes(time_seconds): + """ + Will convert seconds to minutes. + :param time_seconds: The time in seconds. + :return: The time in minutes. + """ minutes = int(time_seconds // 60) seconds = int(time_seconds % 60) - if minutes < 10: - ms = f"0{minutes}" - else: - ms = f"{minutes}" - if seconds < 10: - s = f"0{seconds}" - else: - s = f"{seconds}" + ms = f"0{minutes}" if minutes < 10 else f"{minutes}" + 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. + :return: The arguments that will be used to execute the test cases. + """ parser = argparse.ArgumentParser(description="Parallel Testing") parser.add_argument( "markers", @@ -255,6 +269,11 @@ def __arg_parser(): def __str2bool(v): + """ + Will convert a string to a boolean. + :param v: The string to be converted. + :return: The boolean value. + """ if isinstance(v, bool): return v @@ -267,6 +286,12 @@ def __str2bool(v): def __start_processes(markers: list, args, test_run_id=None): + """ + Will start the processes for parallel test execution. + :param markers: The markers to be used. + :param args: The arguments to be used. + :param test_run_id: The test run id. + """ ps = [] amount = len(markers) // args.parallel amount_plus = 0 @@ -299,6 +324,12 @@ def __start_processes(markers: list, args, test_run_id=None): def __start_run_id(args, test_run_name): + """ + Will start the test run id. + :param args: The arguments to be used. + :param test_run_name: The test run name. + :return: The test run id. + """ end_marker = "" for i, marker in enumerate(args.markers): if i == len(args.markers): @@ -349,6 +380,13 @@ def __start_run_id(args, test_run_name): def __process(markers: list, args, thread=0, test_run_id=None): + """ + Will start a single test process. + :param markers: The markers to be used. + :param args: The arguments to be used. + :param thread: The thread number. + :param test_run_id: The test run id. + """ for marker in markers: try: os.remove(f".my_cache_dir_{thread}/v/cache/lastfailed") @@ -394,6 +432,10 @@ def __process(markers: list, args, thread=0, test_run_id=None): def __set_fails_file(cache_dir): + """ + Will set the fails file. + :param cache_dir: The cache directory. + """ fails = __check_fails(cache_dir) for fail in fails: file = open("report_fails.txt", "a+") @@ -402,6 +444,11 @@ def __set_fails_file(cache_dir): def __check_number_of_fails(): + """ + Will check the number of fails within report file. + :return: The number of fails. + + """ try: file = open("report_fails.txt") fails = file.read() @@ -412,6 +459,10 @@ def __check_number_of_fails(): def __check_txt_fails(): + """ + Will return content of reported fails. + :return: The fails. + """ try: file = open("report_fails.txt") text = file.read() @@ -422,6 +473,11 @@ def __check_txt_fails(): def __check_fails(cache=".pytest_cache"): + """ + Will check latest fails wihin cache. + :param cache: The cache directory. + :return: The fails. + """ try: f = open(f"{cache}/v/cache/lastfailed") fails = f.read() @@ -443,6 +499,11 @@ def __check_fails(cache=".pytest_cache"): def __clean_str(string: str): + """ + Will clean the provided string from unnecessary characters. + :param string: The string to be cleaned. + :return: The cleaned string. + """ return ( string.replace('"', "") .replace("}", "") diff --git a/testui/support/testui_driver.py b/testui/support/testui_driver.py index de8c561..1c0adb7 100644 --- a/testui/support/testui_driver.py +++ b/testui/support/testui_driver.py @@ -18,6 +18,10 @@ 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): @@ -35,6 +39,12 @@ def __init__(self, driver): self.__configuration: Configuration = driver.configuration def switch_to_context(self, context=0, last=False): + """ + Switch to a specific context + :param context: context number + :param last: if True, switch to the last context + :return: TestUIDriver + """ if last: context = len(self.__appium_driver.contexts) - 1 try: @@ -66,17 +76,33 @@ def switch_to_context(self, context=0, last=False): @property def context(self): + """ + Returns the current context + :return: current context + """ return self.__appium_driver.contexts def e(self, locator_type, locator): + """ + This method is meant for Selenium or Browser views (Mobile and Desktop) + :param locator_type: + :param locator: + :return: Elements + """ return e(self, locator_type, locator) def execute(self, driver_command, params=None): + """ + This method is meant for Appium Drivers Only + :param driver_command: + :param params: + :return: + """ self.get_driver().execute(driver_command, params) def remove_log_file(self, when_no_errors=True): """ - removes appium log file. If when_no_errors is False, it will always + 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: @@ -92,47 +118,88 @@ def remove_log_file(self, when_no_errors=True): def touch_actions(self): """ - This method is meant for Appium Drivers Only + Will return a TouchAction object for the current driver. This is + meant for Appium Drivers only. :return: """ return TouchAction(self.get_driver()) def actions(self): """ - This method is meant for Selenium or Browser views (Mobile and Desktop) - :return: + Will return an ActionChains object for the current driver. """ return ActionChains(self.get_driver()) def open_notifications(self): + """ + Will open the notifications panel on the device. This method is meant + for Appium Drivers only + :return: + """ self.get_driver().open_notifications() def back(self): + """ + Will perform a back action on the device in browser history. + :return: + """ self.get_driver().back() return self def quit(self, stop_server=True): + """ + Will quit the driver and stop the server if stop_server is True. + :param stop_server: + :return: + """ 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. + :param url: + :return: + """ self.get_driver().get(url) logger.log(f"{self.device_name}: Navigating to: {url}") def execute_script(self, driver_command, args: None): + """ + Will execute a JavaScript script in the current window/frame. + :param driver_command: + :param args: + :return: + """ self.get_driver().execute_script(driver_command, args) @property def switch_to(self): + """ + Will return a SwitchTo object describing all options to switch focus. + This method is meant for Appium Drivers only. + :return: + """ return self.get_driver().switch_to def set_network_connection(self, number): - self.__appium_driver.set_network_connection(number) + """ + Will set the network connection type based on the network connection + number. This method is meant for Appium Drivers Only + :param number: + :return: + """ + self.get_driver().set_network_connection(number) @property def network_connection(self): - return self.__appium_driver.network_connection + """ + Get the current network connection type. This method is meant for + Appium Drivers only. + :return: + """ + return self.get_driver().network_connection def find_image_match( self, @@ -142,6 +209,16 @@ def find_image_match( not_found=False, image_match="", ): + """ + Will find an image match based on the comparison type and threshold + within the current screen. + :param comparison: + :param threshold: + :param assertion: + :param not_found: + :param image_match: + :return: bool + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d%H%M%S") image_name = f"{self.device_udid}{current_time}.png" @@ -161,6 +238,14 @@ def find_image_match( return found 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 + within the current screen. + :param image: + :param threshold: + :param webview: + :return: + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d%H%M%S") image_name = f"{self.device_udid}{current_time}.png" @@ -176,6 +261,10 @@ def click_by_image(self, image: str, threshold=0.9, webview=False): self.__delete_screenshot(im_path) def get_dimensions(self): + """ + Will return the dimensions of the current screen. + :return: + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d%H%M%S") image_name = f"{self.device_udid}{current_time}.png" @@ -188,11 +277,24 @@ def get_dimensions(self): return dimensions def click(self, x, y): + """ + Will execute a touch action on the current screen based on the x and y + coordinates. + :param x: + :param y: + :return: + """ ta = TouchAction(self.__appium_driver) ta.tap(x=x, y=y).perform() logger.log(f'Clicked over "x={x}: y={y}"') def save_screenshot(self, image_name=""): + """ + 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: + """ config = self.__configuration root_dir = config.screenshot_path @@ -221,21 +323,47 @@ def save_screenshot(self, image_name=""): @classmethod def __delete_screenshot(cls, image_name): + """ + Will delete the provided screenshot. + :param image_name: + :return: + """ + root_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) os.remove(image_name) def get_driver(self) -> WebDriver: + """ + Will return the current driver. + :return: WebDriver + """ driver = self.__appium_driver return driver @property def configuration(self) -> Configuration: + """ + Will return the current configuration. + :return: Configuration + """ return self.__configuration def set_error(self, error): + """ + Will add an error to the current list of errors. + :param error: + :return: + """ self.errors.append(error) def raise_errors(self, remove_log_file=False): + """ + Will raise all the errors in the current list of errors. + :param remove_log_file: If True, appium logs will be deleted. + :return: + """ if len(self.errors) != 0: composed_error = "\n" i = 1 @@ -248,9 +376,18 @@ def raise_errors(self, remove_log_file=False): self.remove_log_file() def get_clipboard_text(self) -> str: - return self.__appium_driver.get_clipboard_text() + """ + Will return the current clipboard text. + :return: The current 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: + """ try: self.get_driver().set_power_capacity(capacity) except WebDriverException as wd_exception: @@ -261,18 +398,42 @@ def set_power_capacity(self, capacity: int): raise Exception(exception) from wd_exception def background_app(self, seconds): + """ + Will background the current app for the provided seconds. + :param seconds: The seconds to background the app. + :return: + """ self.get_driver().background_app(seconds) def remove_app(self, app_id): + """ + Will remove the provided app from the current device. + :param app_id: The app id to remove. + :return: + """ self.get_driver().remove_app(app_id) def install_app(self, app_id): + """ + Will install the provided app in the current device. + :param app_id: The app id to install. + :return: + """ self.get_driver().install_app(app_id) def start_recording_screen(self): + """ + Start recording the screen on current device. + :return: + """ self.get_driver().start_recording_screen() 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: + """ file = self.get_driver().stop_recording_screen() decoded_string = base64.b64decode(file) root_dir = self.configuration.screenshot_path @@ -288,7 +449,17 @@ def stop_recording_and_compare( not_found=False, keep_image_as="", assertion=True, - ): + ) -> bool: + """ + Stop recording the screen and compare the video with the given image + :param comparison: + :param threshold: + :param fps_reduction: + :param not_found: + :param keep_image_as: + :param assertion: + :return: True if the image was found in the video, False otherwise + """ now = datetime.now() current_time = now.strftime("%Y-%m-%d%H%M%S") video_name = f"{self.device_udid}{current_time}.mp4" @@ -328,7 +499,17 @@ def stop_recording_and_compare( return True def new_error_message(self, message) -> str: + """ + Create new error message with device name included + :param message: + :return: The error message + """ return f"{self.device_name}: {message}" def hide_keyboard(self): + """ + Hide the keyboard if it is showing. + This method is meant for Appium Drivers Only. + :return: + """ self.get_driver().hide_keyboard() diff --git a/testui/support/testui_images.py b/testui/support/testui_images.py index 08ad007..ef5a561 100644 --- a/testui/support/testui_images.py +++ b/testui/support/testui_images.py @@ -22,6 +22,16 @@ def compare_video_image( max_scale=2.0, path="" ): + """ + Compare an image to a video and return the percentage of similarity + :param video: the video to compare + :param comparison: the image to compare + :param threshold: the threshold of similarity + :param image_match: the image to save if a match is found + :param frame_rate_reduction: the frame rate reduction + :param max_scale: the maximum scale of the image + :return: True if a match is found, False otherwise + """ global matching_list global matched global found_image @@ -69,6 +79,18 @@ def __compare( min_scale=0.1, divisions=25 ): + """ + Compare a template image to a larger image and return the percentage of + similarity + :param image: the larger image + :param template: the template image + :param threshold: the threshold of similarity + :param image_match: the image to save if a match is found + :param root_dir: the root directory of the project + :param max_scale: the maximum scale of the image + :param min_scale: the minimum scale of the image + :return: True if a match is found, False otherwise + """ (tH, tW) = template.shape[:2] # loop over the scales of the image found = None @@ -133,6 +155,16 @@ def compare_images( min_scale=0.3, path="" ): + """ + Compare two images and return a boolean if they are similar or not + :param original: The original image + :param comparison: The image to compare + :param threshold: The threshold to compare the images + :param image_match: The image to save the match + :param max_scale: The maximum scale to compare the images + :param min_scale: The minimum scale to compare the images + :return: A boolean if the images are similar or not + """ # Read the images from the file global found_image global matched @@ -245,6 +277,14 @@ def compare_images( def get_point_match( original: str, comparison: str, threshold=0.9, device_name="Device" ): + """ + Get the point where the images match. If the images don't match, return None + :param original: The original image + :param comparison: The image to compare to + :param threshold: The threshold to match the images + :param device_name: The device name + :return: The point where the images match + """ _ = device_name # Read the images from the file @@ -283,6 +323,13 @@ def get_point_match( def draw_match( original: str, comparison: str, threshold=0.9, device_name="Device" ): + """ + Draws a rectangle around the match of the two images. + :param original: The original image + :param comparison: The image to compare to + :param threshold: The threshold to match the images + :param device_name: The device name + """ method = cv2.TM_CCOEFF_NORMED # Read the images from the file @@ -305,13 +352,23 @@ def draw_match( cv2.imwrite("something.png", suh) + def size(image_path): + """ + Gets the size of an image. + :param image_path: The path to the image. + :return: The width and height of the image. + """ img = cv2.imread(image_path) height, width, _ = img.shape return width, height class ImageRecognition: + """ + Class for image recognition. + """ + def __init__( self, original: str, comparison="", threshold=0.9, device_name="Device", path="" ): @@ -326,6 +383,13 @@ def __init__( self.__path = path def compare(self, image_match="", max_scale=2.0, min_scale=0.3): + """ + Compares the image to a given image. + :param image_match: The image to compare to. + :param max_scale: The maximum scale to compare the image to. + :param min_scale: The minimum scale to compare the image to. + :return: True if the image is found, False if not. + """ _, p1 = compare_images( self.__original, self.__comparison, @@ -353,6 +417,14 @@ def compare(self, image_match="", max_scale=2.0, min_scale=0.3): def compare_video( self, image_match="", frame_rate_reduction=1, max_scale=2.0 ): + """ + Compares the image to a video + :param image_match: The image to match + :param frame_rate_reduction: The frame rate reduction + :param max_scale: The max scale + :return: True if the image is found in the video + """ + found, p = compare_video_image( self.__original, self.__comparison, @@ -378,6 +450,10 @@ def compare_video( return False def get_middle_point(self): + """ + Returns the middle point of the image match + :return: ImageRecognition + """ get_point_match( self.__original, self.__comparison, @@ -387,6 +463,10 @@ def get_middle_point(self): return self def draw_image_match(self): + """ + Draws a rectangle around the image match + :return: ImageRecognition + """ draw_match( self.__original, self.__comparison, @@ -396,6 +476,10 @@ def draw_image_match(self): return self def image_original_size(self): + """ + Returns the size of the original image + :return: Dimensions + """ path = self.__original if self.__path != "": path = os.path.join(self.__path, self.__original) @@ -405,6 +489,10 @@ def image_original_size(self): return Dimensions(size_image[0], size_image[1]) def image_comparison_size(self): + """ + Returns the size of the comparison image + :return: Dimensions + """ size_image = size(self.__comparison) logger.log(f"The size of the image is {size_image}") return Dimensions(size_image[0], size_image[1]) @@ -412,6 +500,15 @@ def image_comparison_size(self): def crop_original_image( self, center_x, center_y, width, height, image_name="cropped_image.png" ): + """ + Crops the original image and saves it in the root directory + :param center_x: int + :param center_y: int + :param width: int + :param height: int + :param image_name: str + :return: ImageRecognition + """ # Read the images from the file path = os.path.join(self.__path, self.__original) img = cv2.imread(path) @@ -427,6 +524,10 @@ def crop_original_image( class Dimensions: + """ + Class to store the dimensions of an image + """ + def __init__(self, x, y): self.x = x self.y = y