Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 19 additions & 30 deletions test/e2e_appium/core/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,11 @@ def settings(self):

@property
def welcome_back(self):
class SimpleWelcomeBack:
def is_welcome_back_screen_displayed(self, timeout=10):
return False

def perform_login(self, password):
return False
if not self._welcome_back:
from pages.onboarding import WelcomeBackPage

return SimpleWelcomeBack()
self._welcome_back = WelcomeBackPage(self.driver)
return self._welcome_back

@property
def user_service(self) -> UserProfileService:
Expand Down Expand Up @@ -267,8 +264,15 @@ def restart_app_and_login(self) -> bool:
self.logger.error("App restart failed")
return False

# Wait for app to stabilize and present either home or auth
self.wait_for_app_post_restart()
try:
manager = self._app_initialization or AppInitializationManager(self.driver)
if not self._app_initialization:
self._app_initialization = manager
manager.perform_initial_activation(timeout=3)
except Exception as activation_err:
self.logger.debug(
"Post-restart activation attempt failed: %s", activation_err
)

# Detect new state and handle authentication
self.app_state_manager.detect_current_state()
Expand All @@ -289,14 +293,18 @@ def get_home(self, ensure: bool = True, auto_create: bool = True) -> HomePage:

if not self.app_state.is_home_loaded:
# Try existing-user login if authentication is required
if self.app_state.requires_authentication:
if self.app_state.requires_authentication and self.user_service.current_user:
try:
self.logger.info(
"Auth required - attempting existing user login"
)
self.login_existing_user()
except Exception as e:
self.logger.warning(f"Existing user login failed: {e}")
elif self.app_state.requires_authentication:
self.logger.info(
"Auth required but no known test user; skipping auto-login"
)

# If still not loaded and allowed, create a user
if not self.app_state.is_home_loaded and auto_create:
Expand Down Expand Up @@ -347,6 +355,7 @@ def cleanup(self):
self._user_service = None
self._app_state_manager = None
self._app_initialization = None
self._welcome_back = None

self.logger.info("✅ TestContext cleanup completed")

Expand Down Expand Up @@ -418,26 +427,6 @@ def _handle_post_restart_authentication(self) -> bool:

return False

def wait_for_app_post_restart(
self, timeout: Optional[int] = None, poll_interval: float = 0.5
) -> bool:
"""Public helper to wait for app readiness after a restart using YAML defaults."""
effective_timeout = timeout
try:
if (
effective_timeout is None
and self._session_manager
and self._session_manager.env_config
):
effective_timeout = self._session_manager.env_config.timeouts.get(
"default", 30
)
except Exception:
effective_timeout = effective_timeout or 30
return self._wait_for_app_ready(
timeout=int(effective_timeout or 30), poll_interval=poll_interval
)

def _wait_for_app_ready(
self, timeout: int = 30, poll_interval: float = 0.5
) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

class WalletLocators(BaseLocators):

WALLET_HEADER = BaseLocators.accessibility_id("Wallet")
WALLET_HEADER = BaseLocators.content_desc_contains("walletHeader")
WALLET_FOOTER_SEND_BUTTON = BaseLocators.xpath(
"//android.view.View.VirtualChild[@content-desc='Send [tid:walletFooterSendButton]']"
"//*[contains(@resource-id, 'walletFooterSendButton')]"
)
ASSETS_TAB = BaseLocators.text_contains("Assets")
ACTIVITY_TAB = BaseLocators.text_contains("Activity")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@


class WelcomeBackScreenLocators(BaseLocators):
"""Locators for the Welcome Back screen (returning users)."""
"""Locators for the Welcome Back (returning user) screen."""

# TODO: Replace fallbacks with accessibility_id/tid

# Screen identification
LOGIN_SCREEN = BaseLocators.xpath(
"//*[contains(@resource-id, 'LoginScreen_QMLTYPE')]"
Expand All @@ -16,36 +14,10 @@ class WelcomeBackScreenLocators(BaseLocators):
"QGuiApplication.mainWindow.startupOnboardingLayout"
)

# User selection elements
USER_SELECTOR = BaseLocators.xpath(
"//*[contains(@resource-id, 'loginUserSelector')]"
)
USER_SELECTOR_DELEGATE = BaseLocators.xpath(
"//*[contains(@resource-id, 'LoginUserSelectorDelegate_QMLTYPE')]"
)

# Password input elements
PASSWORD_BOX = BaseLocators.xpath("//*[contains(@resource-id, 'passwordBox')]")
PASSWORD_INPUT = BaseLocators.xpath(
"//*[contains(@resource-id, 'loginPasswordInput')]"
)
PASSWORD_INPUT_BY_DESC = BaseLocators.content_desc_exact("Password")

# Login action
LOGIN_BUTTON = BaseLocators.xpath("//*[contains(@resource-id, 'loginButton')]")
LOGIN_BUTTON_BY_DESC = BaseLocators.content_desc_exact("Log In")

# Fallback locators for robustness
LOGIN_BUTTON_FALLBACKS = [
BaseLocators.xpath("//*[contains(@resource-id, 'loginButton')]"),
BaseLocators.content_desc_exact("Log In"),
BaseLocators.text_exact("Log In"),
]

PASSWORD_INPUT_FALLBACKS = [
BaseLocators.xpath("//*[contains(@resource-id, 'loginPasswordInput')]"),
BaseLocators.content_desc_exact("Password"),
BaseLocators.xpath(
"//android.widget.EditText[contains(@content-desc, 'Password')]"
),
]
PASSWORD_INPUT_OVERLAY = BaseLocators.xpath(
"//*[contains(@resource-id, 'loginPasswordInput')]"
)
LOGIN_BUTTON = BaseLocators.content_desc_contains("[tid:loginButton]")
34 changes: 34 additions & 0 deletions test/e2e_appium/locators/settings/password_change_locators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from ..base_locators import BaseLocators


class PasswordChangeLocators(BaseLocators):
CURRENT_PASSWORD_CONTAINER = BaseLocators.content_desc_exact(
"Enter current password"
)
CURRENT_PASSWORD_INPUT = BaseLocators.xpath(
"//*[contains(@resource-id, 'passwordViewCurrentPassword')]"
)
NEW_PASSWORD_INPUT = BaseLocators.xpath(
"//*[contains(@resource-id, 'passwordViewNewPassword') and not(contains(@resource-id, 'Confirm'))]"
)
CONFIRM_PASSWORD_INPUT = BaseLocators.xpath(
"//*[contains(@resource-id, 'passwordViewNewPasswordConfirm')]"
)
CHANGE_PASSWORD_BUTTON = BaseLocators.xpath(
"//*[contains(@resource-id, 'changePasswordModalSubmitButton') or "
"contains(@content-desc, 'changePasswordModalSubmitButton')]"
)


class ChangePasswordModalLocators(BaseLocators):
MODAL_CONTAINER = BaseLocators.xpath(
"//*[@resource-id='QGuiApplication.mainWindow.ConfirmChangePasswordModal']"
)
PRIMARY_BUTTON = BaseLocators.xpath(
"//*[@resource-id='QGuiApplication.mainWindow.ConfirmChangePasswordModal']"
"//*[contains(@content-desc, 'tid:changePasswordModalSubmitButton')]"
)
STATUS_MESSAGE = BaseLocators.xpath(
"//*[@resource-id='QGuiApplication.mainWindow.ConfirmChangePasswordModal']"
"//*[contains(@resource-id, 'statusListItemSubTitle')]"
)
2 changes: 2 additions & 0 deletions test/e2e_appium/locators/settings/settings_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class SettingsLocators(BaseLocators):
BACKUP_RECOVERY_MENU_ITEM = BaseLocators.content_desc_contains("[tid:19-MenuItem]")

PROFILE_MENU_ITEM = BaseLocators.xpath("//*[contains(@resource-id,'0-MenuItem')]")
PASSWORD_MENU_ITEM = BaseLocators.content_desc_contains("[tid:1-MenuItem]")
PASSWORD_MENU_ITEM_TEXT = BaseLocators.text_contains("Password")

SIGN_OUT_AND_QUIT = BaseLocators.text_contains("Sign out & Quit")
SIGN_OUT_AND_QUIT_ALT = BaseLocators.xpath(
Expand Down
2 changes: 2 additions & 0 deletions test/e2e_appium/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
CreateProfilePage,
PasswordPage,
SplashScreen,
WelcomeBackPage,
)

__all__ = [
Expand All @@ -20,4 +21,5 @@
"CreateProfilePage",
"PasswordPage",
"SplashScreen",
"WelcomeBackPage",
]
1 change: 1 addition & 0 deletions test/e2e_appium/pages/base_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def safe_click(
)
self.logger.error(message)
self.take_screenshot(f"click_failure_{locators_to_try[0][1]}")
self.dump_page_source(f"click_failure_{locators_to_try[0][1]}")
raise ElementInteractionError(message, str(locators_to_try[0]), "click")

def safe_input(self, locator, text: str, timeout: Optional[int] = None) -> bool:
Expand Down
2 changes: 2 additions & 0 deletions test/e2e_appium/pages/onboarding/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .loading_page import SplashScreen
from .home_page import HomePage
from .seed_phrase_input_page import SeedPhraseInputPage
from .welcome_back_page import WelcomeBackPage
from .main_app_page import MainAppPage

__all__ = [
Expand All @@ -17,5 +18,6 @@
"SplashScreen",
"HomePage",
"SeedPhraseInputPage",
"WelcomeBackPage",
"MainAppPage",
]
150 changes: 150 additions & 0 deletions test/e2e_appium/pages/onboarding/welcome_back_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import time

from ..base_page import BasePage
from locators.onboarding.welcome_back_screen_locators import WelcomeBackScreenLocators
from services.app_initialization_manager import AppInitializationManager
from utils.element_state_checker import ElementStateChecker


class WelcomeBackPage(BasePage):
def __init__(self, driver):
super().__init__(driver)
self.locators = WelcomeBackScreenLocators()

def is_welcome_back_screen_displayed(self, timeout: int = 5) -> bool:
checks = [
self.locators.LOGIN_SCREEN,
self.locators.PASSWORD_INPUT,
self.locators.LOGIN_BUTTON,
]

for locator in checks:
if not locator:
continue
element = self.find_element_safe(locator, timeout=timeout)
if element and ElementStateChecker.is_displayed(element):
return True

return False

def perform_login(self, password: str, timeout: int = 60) -> bool:
self._activate_screen_if_needed()

max_attempts = 2
for attempt in range(1, max_attempts + 1):
if not self._focus_password_field():
self.logger.error("Failed to focus password field on attempt %s", attempt)
return False

if not self.qt_safe_input(
self.locators.PASSWORD_INPUT, password, verify=False
):
self.logger.error("Password input failed on attempt %s", attempt)
return False

try:
self.hide_keyboard()
except Exception:
pass

if not self._wait_for_button_enabled(timeout=10):
self.logger.error("Login button never enabled on attempt %s", attempt)
return False

if not self.safe_click(self.locators.LOGIN_BUTTON, timeout=10):
self.logger.error("Login button click failed on attempt %s", attempt)
return False

if self._wait_for_login_transition(timeout=10):
return True

if attempt < max_attempts:
self.logger.warning(
"Login attempt %s did not dismiss welcome back screen; retrying",
attempt,
)
time.sleep(1.0)

self.logger.error("Welcome back screen persisted after login retries")
return False

def _activate_screen_if_needed(self) -> None:
try:
manager = AppInitializationManager(self.driver)
manager.perform_initial_activation(timeout=3)
except Exception:
try:
size = self.driver.get_window_size()
self.gestures.tap(size["width"] // 2, size["height"] // 2)
except Exception:
pass

def _focus_password_field(self, retries: int = 5, wait_between: float = 2.0) -> bool:
for attempt in range(retries):
overlay = self.find_element_safe(
self.locators.PASSWORD_INPUT_OVERLAY, timeout=2
)
if overlay and ElementStateChecker.is_displayed(overlay):
try:
rect = overlay.rect
tap_x = int(rect.get("x", 0) + rect.get("width", 0) * 0.25)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this manual positioning?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some cases there was some overlay preventing the component we want being reached, clicking manually let us reach the right one. Also due to the way Qt components are exposed I haven't found the right locator for some of them yet.

I intend to revisit all instances of using coordinates to try and keep them to a minimum (I'll be updating qml where necessary to add ids as well).

tap_y = int(rect.get("y", 0) + rect.get("height", 0) * 0.25)
if not self.gestures.tap(tap_x, tap_y):
self.gestures.double_tap(tap_x, tap_y)
except Exception:
self.logger.debug(
"Overlay tap failed on attempt %s", attempt + 1
)
time.sleep(wait_between)

field = self.find_element_safe(self.locators.PASSWORD_INPUT, timeout=3)
if not field:
time.sleep(wait_between)
continue

if not ElementStateChecker.is_displayed(field):
time.sleep(wait_between)
continue

if ElementStateChecker.is_focused(field):
return True

try:
rect = field.rect
tap_x = int(rect.get("x", 0) + rect.get("width", 0) * 0.25)
tap_y = int(rect.get("y", 0) + rect.get("height", 0) * 0.25)
if not self.gestures.tap(tap_x, tap_y):
self.gestures.double_tap(tap_x, tap_y)
except Exception:
self.logger.debug("Password field tap failed on attempt %s", attempt + 1)

time.sleep(wait_between)

refreshed = self.find_element_safe(self.locators.PASSWORD_INPUT, timeout=1)
if refreshed and ElementStateChecker.is_focused(refreshed):
return True

time.sleep(wait_between)

self.logger.warning("Unable to focus password input on welcome back screen")
return False

def _wait_for_button_enabled(self, timeout: int = 10) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
try:
button = self.find_element(self.locators.LOGIN_BUTTON, timeout=2)
if str(button.get_attribute("enabled")).lower() == "true":
return True
except Exception:
pass
time.sleep(0.3)
return False

def _wait_for_login_transition(self, timeout: int = 10) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if not self.is_welcome_back_screen_displayed(timeout=1):
return True
time.sleep(0.5)
return False
Loading