Files
status-app/test/e2e_appium/core/device_context.py
T
Mag. 2d345e74f2 fix(e2e_appium): stabilise messaging gate (#21021)
- Messaging tests now share one fixture per test session.
- Chat row locators include FIRST_CHAT_ITEM as a last fallback.
- test_wallet_accounts_basic now calls app.click_wallet_button() before checking the wallet panel, like every other wallet test.
- Onboarding's wallet-landing check is faster.
- BrowserStack default device is now a portrait phone (Pixel 8).
- test_import_seed_phrase moved from gate to smoke
2026-05-26 11:54:10 +01:00

418 lines
17 KiB
Python

from typing import Any
import time
from appium.webdriver.webdriver import WebDriver
from config.logging_config import get_logger
from core.models import TestUser
from fixtures.onboarding_fixture import OnboardingConfig, OnboardingFlow, OnboardingFlowError
from utils.exceptions import SessionManagementError
class DeviceState:
"""Internal state tracking for a device context."""
def __init__(self):
self.user: TestUser | None = None
self._custom_state: dict[str, Any] = {}
class DeviceContext:
__test__ = False
def __init__(self, driver: WebDriver, device_id: str, device_config: dict[str, Any] | None = None):
self.driver = driver
self.device_id = device_id
self.device_config = device_config or {}
self._state = DeviceState()
self.logger = get_logger(f"device_{device_id}")
@property
def user(self) -> TestUser | None:
return self._state.user
@user.setter
def user(self, value: TestUser):
self._state.user = value
self.logger.debug("User state updated: %s", value.display_name if value else None)
async def onboard_user(
self,
config: OnboardingConfig | None = None,
display_name: str | None = None,
password: str | None = None,
) -> TestUser:
import asyncio
self.logger.info("Starting user onboarding on device %s", self.device_id)
if config is None:
config = OnboardingConfig()
if display_name:
config.custom_display_name = display_name
if password:
config.custom_password = password
def _onboard():
try:
flow = OnboardingFlow(self.driver, config, self.logger)
result = flow.execute_complete_flow()
if not result.get("success", False):
raise SessionManagementError(
f"Onboarding failed on device {self.device_id}: {result.get('error', 'Unknown error')}"
)
user_data = result.get("user_data", {})
if not user_data:
raise SessionManagementError(
f"Onboarding completed but no user data returned on device {self.device_id}"
)
test_user = TestUser.from_onboarding_result(user_data, config)
self.user = test_user
self.logger.info(
"User onboarded successfully on device %s: %s",
self.device_id,
test_user.display_name,
)
return test_user
except OnboardingFlowError as e:
self.logger.error(
"OnboardingFlowError on device %s: %s",
self.device_id,
e,
)
raise SessionManagementError(
f"Failed to onboard user on device {self.device_id}: {e}"
) from e
except Exception as e:
self.logger.error(
"Unexpected error during onboarding on device %s: %s",
self.device_id,
e,
)
raise SessionManagementError(
f"Unexpected error during onboarding on device {self.device_id}: {e}"
) from e
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _onboard)
def get_state(self, key: str, default: Any = None) -> Any:
return self._state._custom_state.get(key, default)
def set_state(self, key: str, value: Any) -> None:
self._state._custom_state[key] = value
self.logger.debug("State updated: %s = %s", key, value)
def clear_state(self) -> None:
self._state._custom_state.clear()
self.logger.debug("Custom state cleared")
def capture_profile_link(self) -> str | None:
"""Capture the user's profile link.
On Android the "Copy link to profile" action (``userStatusCopyLinkAction``)
is permanently disabled in QML (``enabled: !SQUtils.Utils.isMobile``), so
we go directly to the mobile "Invite contacts" path via ShareProfileDialog.
Tries strategies in order; the second attempt at messages-panel exists
because ``_restore_main_ui``'s ``activate_app`` shake unsticks the
drawer's gesture handler, so a fresh whole-path retry materially helps
if the inner retries inside the first call exhausted. The Settings
path uses a different QML route (Profile menu → "Invite contacts") and
recovers when both messages-panel attempts hit the same flake.
Returns the captured link, otherwise ``None``.
"""
from pages.app import App
self.logger.info("Capturing profile link for device %s", self.device_id)
main_app = App(self.driver)
strategies = (
("messages-panel", self._capture_via_messages_panel),
("messages-panel retry after activate_app()", self._capture_via_messages_panel),
("settings 'Invite contacts'", self._capture_via_settings),
)
for name, strategy in strategies:
self.logger.info("Trying profile-link strategy: %s", name)
try:
link = strategy(main_app)
except Exception as exc:
self.logger.warning("Strategy '%s' raised: %s", name, exc)
link = None
# The overlay cleanup uses driver.back() which can over-navigate
# on Android — _restore_main_ui foregrounds the app again between
# strategies so each gets a clean starting state.
self._restore_main_ui(main_app)
if link:
self.logger.info("Profile link captured via '%s': %s", name, link)
self.set_state("profile_link", link)
if self.user:
self.user.profile_link = link
return link
self.logger.warning("Strategy '%s' returned no link", name)
self.logger.error("All profile-link capture paths failed")
return None
def _capture_via_messages_panel(self, app) -> str | None:
"""Capture profile link via the Messages section's
shareProfileButton (ContactsColumnView header).
The drawer's profile-avatar tap is unusable on BS portrait — the
avatar exposes as ``android.app.ActionBar.Tab`` with
``clickable=false``, so both ``element.click()`` and
``mobile: clickGesture`` no-op.
"""
from locators.app_locators import AppLocators
from locators.messaging.chat_locators import ChatLocators
from locators.settings.profile_locators import ProfileSettingsLocators
from pages.settings.share_profile_dialog import ShareProfileDialog
from utils.gestures import Gestures
gestures = Gestures(self.driver)
app_locators = AppLocators()
chat_locators = ChatLocators()
profile_locators = ProfileSettingsLocators()
overlays_to_dismiss = 0
try:
on_messages = False
max_outer_attempts = 5
for outer_attempt in range(1, max_outer_attempts + 1):
self.logger.info(
"Messages-nav outer attempt %d/%d", outer_attempt, max_outer_attempts
)
app._ensure_main_nav_visible()
time.sleep(1.5) # drawer slide-in animation settle
max_taps = 5
for tap_attempt in range(1, max_taps + 1):
if not app.is_element_visible(
app_locators.LEFT_NAV_SETTINGS, timeout=1
):
self.logger.info(
"Drawer closed after %d tap(s)", tap_attempt - 1
)
break
msg_el = app.find_element_safe(
app_locators.LEFT_NAV_MESSAGES, timeout=3
)
if msg_el is None:
self.logger.warning(
"LEFT_NAV_MESSAGES not visible at tap attempt %d",
tap_attempt,
)
app._ensure_main_nav_visible()
time.sleep(1.0)
continue
rect = msg_el.rect
cx = int(rect["x"] + rect["width"] / 2)
cy = int(rect["y"] + rect["height"] / 2)
self.logger.info(
"Messages-navbar W3C-pointer click attempt %d at (%d,%d) "
"rect=[x=%d,y=%d,w=%d,h=%d]",
tap_attempt, cx, cy,
rect["x"], rect["y"], rect["width"], rect["height"],
)
if not gestures.tap(cx, cy):
self.logger.warning(
"W3C-pointer click failed on attempt %d", tap_attempt
)
# Brief wait so the next drawer-closed check sees
# post-tap state; the real patience is in the 30s
# SHARE_PROFILE_BUTTON poll below.
time.sleep(2.0)
# Dismiss backup-recovery popup that may overlay
# Messages section. mobile:clickGesture with elementId
# — element.click() has the same BS-portrait mis-routing.
for dismiss_attempt in range(1, 4):
skip_el = app.find_element_safe(chat_locators.BACKUP_SKIP_BUTTON, timeout=2)
if skip_el is None:
break
self.logger.info(
"Dismissing backup popup via clickGesture (attempt %d)",
dismiss_attempt,
)
try:
self.driver.execute_script(
"mobile: clickGesture",
{"elementId": skip_el.id},
)
except Exception as exc:
self.logger.debug("clickGesture on Skip suppressed: %s", exc)
time.sleep(1.0) # popup dismiss animation
time.sleep(1.0) # let any final overlay settle before polling
# 30s — landmark can take 15-25s on BS portrait (section
# transition + AT-tree settle). Re-tapping resets it.
if app.is_element_visible(profile_locators.SHARE_PROFILE_BUTTON, timeout=30):
on_messages = True
self.logger.info(
"Messages section + shareProfileButton confirmed on outer attempt %d",
outer_attempt,
)
break
self.logger.warning(
"Drawer closed but shareProfileButton not visible — retrying (outer attempt %d)",
outer_attempt,
)
if not on_messages:
self.logger.error(
"Failed to navigate to Messages section after %d outer attempts",
max_outer_attempts,
)
return None
if not app.safe_click(profile_locators.SHARE_PROFILE_BUTTON, timeout=5):
self.logger.error("Failed to click shareProfileButton")
return None
dialog = ShareProfileDialog(self.driver)
if not dialog.is_displayed(timeout=10):
self.logger.error("ShareProfileDialog did not appear after shareProfileButton tap")
return None
overlays_to_dismiss = 1
link = dialog.get_profile_link()
if not link:
self.logger.error("ShareProfileDialog did not contain a profile link")
return None
return link
finally:
dialog_locator = ("xpath", "//*[contains(@resource-id,'ShareProfileDialog')]")
if overlays_to_dismiss and app.is_element_visible(dialog_locator, timeout=1):
try:
self.driver.back()
except Exception:
self.logger.debug("driver.back() suppressed during overlay cleanup")
def _capture_via_settings(self, app) -> str | None:
"""Capture profile link via the mobile 'Invite contacts' flow.
On Android the profile menu exposes "Invite contacts" instead of
"Copy link to profile". Tapping it opens the ShareProfileDialog
which contains the link.
This method ensures all overlays it opens (profile popup and
ShareProfileDialog) are dismissed before returning, so the caller
gets a clean navigation state.
"""
from locators.app_locators import AppLocators
from locators.settings.profile_locators import ProfileSettingsLocators
from pages.settings.share_profile_dialog import ShareProfileDialog
invite_action = AppLocators.SHARE_PROFILE_ACTION
overlays_to_dismiss = 0
try:
invite_visible = app.is_element_visible(invite_action, timeout=2)
if invite_visible:
# Profile popup is already open (e.g. retry after partial failure).
overlays_to_dismiss = 1
else:
if not app.open_profile_menu():
self.logger.error("Failed to open profile menu for invite path")
return None
overlays_to_dismiss = 1
invite_visible = app.is_element_visible(invite_action, timeout=15)
if not invite_visible:
self.logger.error("Invite contacts action not visible in profile menu")
app.dump_page_source("invite_action_not_visible")
return None
try:
app.safe_click(invite_action, timeout=5)
except Exception as exc:
self.logger.error("Failed to click Invite contacts: %s", exc)
return None
dialog = ShareProfileDialog(self.driver)
if not dialog.is_displayed(timeout=10):
self.logger.error("ShareProfileDialog did not appear after invite tap")
return None
# ShareProfileDialog sits on top of the profile popup — two layers.
overlays_to_dismiss = 2
link = dialog.get_profile_link()
if not link:
self.logger.error("ShareProfileDialog did not contain a profile link")
return None
return link
finally:
# Dismiss overlays cautiously — verify each one is still
# present before pressing back, to avoid navigating out of
# the app entirely.
overlay_checks = [
ProfileSettingsLocators.SHARE_PROFILE_DIALOG,
AppLocators.PROFILE_MENU_CONTAINER,
]
for i, check_locator in enumerate(overlay_checks[:overlays_to_dismiss]):
if app.is_element_visible(check_locator, timeout=1):
try:
self.driver.back()
self.logger.debug("driver.back() #%d dismissed overlay", i + 1)
except Exception:
self.logger.debug(
"driver.back() #%d suppressed during overlay cleanup", i + 1
)
else:
self.logger.debug(
"Overlay #%d already gone, skipping back()", i + 1
)
def _restore_main_ui(self, app) -> None:
"""Ensure the Status app is in the foreground with the main nav visible.
After overlay cleanup the app may have been sent to the background
by an extra ``driver.back()``. This method re-activates the app
and waits for the left navigation bar to become visible.
"""
from locators.app_locators import AppLocators
locators = AppLocators()
app.app_lifecycle.activate_app()
# Quick check — if the nav bar is already there, we're done.
if app.is_element_visible(locators.LEFT_NAV_WALLET, timeout=3):
self.logger.debug("Main UI already visible after activate_app()")
return
# The app may need a moment to fully resume. Try activating once
# more, then give it a longer wait.
self.logger.info("Main nav not visible after activate_app(), retrying")
app.app_lifecycle.activate_app()
if not app.is_element_visible(locators.LEFT_NAV_WALLET, timeout=10):
self.logger.warning(
"Main nav still not visible after second activate_app() — "
"subsequent steps may fail"
)
app.dump_page_source("restore_main_ui_failure")