import base64 from time import sleep from pathlib import Path from appium.webdriver.common.appiumby import AppiumBy as By from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException, \ StaleElementReferenceException from helpers.appium.gestures import swipe, tap from helpers.platform.ios.actions import scroll_page as scroll_ios from helpers.platform.android.actions import scroll_page as scroll_android from helpers.platform.android.locators import resource_id_xpath class BaseScreen: active_item_resource_id = 'activeItemPath' active_item_path = resource_id_xpath(active_item_resource_id) def __init__(self, driver): self.driver = driver def waiter(self, time, strategy, locator, hard_wait=0.2, multiple=False): wait = WebDriverWait(self.driver, time) try: if multiple: result = wait.until( EC.presence_of_all_elements_located((strategy, locator))) else: result = wait.until( EC.element_to_be_clickable((strategy, locator))) sleep(hard_wait) return result except StaleElementReferenceException: print( f"[StaleElement] Retrying for locator: ({strategy}, {locator})") sleep(3) return self.waiter(time, strategy, locator, hard_wait, multiple) except TimeoutException as e: raise TimeoutException( f"Timeout waiting for element: ({strategy}, {locator})") from e def wait_for_element_invisibility(self, strategy, locator, timeout=5): try: WebDriverWait(self.driver, timeout).until( EC.invisibility_of_element_located((strategy, locator)) ) except TimeoutException: raise AssertionError( f"Element {locator} is still visible after {timeout} seconds.") def click_with_retry(self, time=None, strategy=None, locator=None, element=None, retries=4): for attempt in range(retries): try: el = element or self.waiter(time, strategy, locator) el.click() return except (StaleElementReferenceException, TimeoutException) as e: if attempt == retries - 1: raise exception_name = e.__class__.__name__ print( f"Attempt {attempt + 1} failed with " f"{exception_name}. Retrying...") sleep(1) def assert_element(self, strategy, locator, soft_wait=5): wait = WebDriverWait(self.driver, soft_wait) try: element = wait.until( EC.element_to_be_clickable((strategy, locator))) except StaleElementReferenceException: sleep(1) element = wait.until( EC.element_to_be_clickable((strategy, locator))) assert element def scroll_and_assert(self, strategy, locator, max_scrolls=3, direction='down', small_scroll=False, wd_timeout=2): last_exception = None for attempt in range(max_scrolls): try: wait = WebDriverWait(self.driver, wd_timeout) return wait.until( EC.element_to_be_clickable((strategy, locator)) ) except (TimeoutException, StaleElementReferenceException) as e: last_exception = e if attempt < max_scrolls - 1: self.scroll_page(direction, small_scroll=small_scroll) sleep(1) raise AssertionError( f"Element was not found after {max_scrolls} scroll attempts. " f"Strategy: {strategy}, Locator: {locator}, " f"Direction: {direction}, Small scroll: {small_scroll}, " f"Wait timeout: {wd_timeout}s. " f"Last exception: " f"{type(last_exception).__name__}: {last_exception}" ) from last_exception def scroll_and_assert_element(self, element): self.driver.execute_script('arguments[0].scrollIntoView(true);', element) assert element.is_displayed(), "Element is not visible on the screen" def is_ios(self): return self.driver.capabilities['platformName'].lower() == 'ios' def scroll_page(self, direction='down', small_scroll=False, times=None): if self.is_ios(): scroll_ios(self.driver, direction, small_scroll, times) else: scroll_android(self.driver, direction, small_scroll, times) def swipe_back(self): if self.is_ios(): self.driver.execute_script("mobile: swipe", {"direction": "right"}) return sleep(1) size = self.driver.get_window_size() y = size["height"] / 2.5 start_x = size["width"] * 0.01 end_x = size["width"] * 0.95 swipe(self.driver, start_x, y, end_x, y) sleep(0.5) def swipe_element_left(self, element): location = element.location size = element.size start_x = location['x'] + size['width'] - 50 # near right edge end_x = location['x'] + 50 # near left edge y = location['y'] + size['height'] // 2 # vertical center self.driver.swipe( start_x=start_x, start_y=y, end_x=end_x, end_y=y) sleep(1) def swipe_element_right(self, element): location = element.location size = element.size start_x = location['x'] + 50 # near left edge end_x = location['x'] + size['width'] - 50 # near right edge y = location['y'] + size['height'] // 2 # vertical center self.driver.swipe( start_x=start_x, start_y=y, end_x=end_x, end_y=y) sleep(1) def press_back_button(self): if self.is_ios(): back_btn = ('(//XCUIElementTypeNavigationBar/' 'XCUIElementTypeOther)[1]') self.driver.find_element(by=By.XPATH, value=back_btn).click() else: self.driver.press_keycode(4) def tap_middle_of_screen(self): # Tap the center of the screen to dismiss the keyboard size = self.driver.get_window_size() middle_x = size['width'] // 2 middle_y = size['height'] // 2 if self.is_ios(): self.driver.execute_script("mobile: tap", { "x": middle_x, "y": middle_y }) else: tap(self.driver, middle_x, middle_y) def swipe_down_to_dismiss(self): size = self.driver.get_window_size() width = size["width"] height = size["height"] start_x = width // 2 start_y = int(height * 0.30) end_y = int(height * 0.80) if self.is_ios(): self.driver.execute_script("mobile: swipe", { "direction": "down", "velocity": 2000 }) else: self.driver.swipe( start_x, start_y, start_x, end_y, duration=400 ) sleep(0.3) sleep(0.5) def click_apply_button(self): self.click_with_retry(10, By.ACCESSIBILITY_ID, 'APPLY') def restart_app(self): d = self.driver caps = d.capabilities or {} app_id = (caps.get("appium:appPackage") or caps.get("appPackage") or caps.get("appium:bundleId") or caps.get("bundleId")) # iOS fallback: ask the driver (not always supported) if not app_id: try: info = d.execute_script("mobile: activeAppInfo", {}) if isinstance(info, dict): app_id = info.get("bundleId") except Exception: pass if not app_id: raise ValueError( f"Can't determine app id (caps keys: {caps.keys()})") d.terminate_app(app_id) d.activate_app(app_id) def click_by_image(self, image_name, max_y, threshold=0.7): base_dir = Path(__file__).resolve().parents[1] image_path = base_dir / "helpers/assets/images" / image_name self.driver.update_settings({ "imageMatchThreshold": threshold, "fixImageTemplateScale": True, }) with open(image_path, "rb") as image_file: image_b64 = base64.b64encode(image_file.read()).decode("utf-8") elements = self.driver.find_elements(By.IMAGE, image_b64) if not elements: raise AssertionError( f'No image matches found for "{image_path}" ' f'with threshold {threshold}' ) valid_elements = [ element for element in elements if element.rect["y"] <= max_y ] if not valid_elements: raise AssertionError( f'Image matches were found for "{image_path}", ' f'but none were in the expected top area (max_y={max_y})' ) # TODO: Replace temporary print debugging with proper logger usage. # Logger integration requires significant page object refactoring, # so print is used here as a temporary lightweight solution. for index, element in enumerate(valid_elements, start=1): rect = element.rect print( f'[IMAGE DEBUG] Valid match #{index}: ' f'x={rect["x"]}, y={rect["y"]}, ' f'width={rect["width"]}, height={rect["height"]}' ) best_element = min(valid_elements, key=lambda el: (el.rect["y"], -el.rect["x"])) best_rect = best_element.rect print( f'[IMAGE DEBUG] Selected best match: ' f'x={best_rect["x"]}, y={best_rect["y"]}, ' f'width={best_rect["width"]}, height={best_rect["height"]}' ) best_element.screenshot("matched_element.png") best_element.click() def is_hidden_behind_bottom_navigation(self, element): safety_margin = 50 bottom_navigation = ( self.driver.find_element(By.ACCESSIBILITY_ID, "Search")) element_bottom = element.rect["y"] + element.rect["height"] navigation_top = bottom_navigation.rect["y"] return element_bottom > navigation_top - safety_margin def tap_after_overlap_check(self, element, max_scrolls=3): for _ in range(max_scrolls): if not self.is_hidden_behind_bottom_navigation(element): self.click_with_retry(3, element=element) break self.scroll_page(small_scroll=True) sleep(1)