From e7d709aed7d093d95e021bad9629a73eb29aac9a Mon Sep 17 00:00:00 2001 From: "Mag." <50769329+glitchminer@users.noreply.github.com> Date: Mon, 11 May 2026 07:10:52 +0100 Subject: [PATCH] e2e_appium: 1x1 messaging gate stability + maintenance --- test/e2e_appium/conftest.py | 50 +++- test/e2e_appium/core/device_context.py | 189 ++++++++++++--- .../locators/messaging/chat_locators.py | 89 +++---- test/e2e_appium/pages/app.py | 178 +++++++++++--- .../pages/onboarding/welcome_back_page.py | 27 +-- .../services/app_initialization_manager.py | 10 +- test/e2e_appium/tests/messaging/conftest.py | 221 +++++++++++++++--- .../tests/messaging/test_emoji_and_media.py | 19 +- .../messaging/test_message_context_menu.py | 45 +++- .../tests/messaging/test_z_chat_management.py | 8 +- test/e2e_appium/tests/test_ios_poc.py | 1 - test/e2e_appium/utils/chat_state.py | 81 +++++++ test/e2e_appium/utils/contact_helpers.py | 29 ++- 13 files changed, 756 insertions(+), 191 deletions(-) create mode 100644 test/e2e_appium/utils/chat_state.py diff --git a/test/e2e_appium/conftest.py b/test/e2e_appium/conftest.py index 8a49a43d72..ba00843864 100644 --- a/test/e2e_appium/conftest.py +++ b/test/e2e_appium/conftest.py @@ -1,9 +1,10 @@ +import faulthandler import multiprocessing import os import time from datetime import datetime from pathlib import Path -from typing import Any, List, Optional +from typing import Any, List, Optional, TextIO import pytest @@ -30,6 +31,46 @@ _logging_setup = None _saved_failure_logs: List[Path] = [] _bs_pending_counter = None _counter_manager: Optional[Any] = None +_faulthandler_log: Optional[TextIO] = None + + +def _install_faulthandler(reports_dir: Path) -> None: + """Periodically dump stacks of ALL threads when E2E_FAULTHANDLER_INTERVAL>0. + + Diagnostic for hangs in pytest-asyncio _scoped_runner shutdown: when + loop.shutdown_default_executor() blocks because a worker thread is + mid-call, the dumps reveal which thread and what it's waiting on. + Opt-in to keep normal runs quiet. + """ + global _faulthandler_log + interval_s = int(os.environ.get("E2E_FAULTHANDLER_INTERVAL", "0")) + if interval_s <= 0: + return + + log_path = reports_dir / "faulthandler.log" + _faulthandler_log = open(log_path, "a", buffering=1) + faulthandler.enable(file=_faulthandler_log, all_threads=True) + faulthandler.dump_traceback_later( + timeout=interval_s, + repeat=True, + file=_faulthandler_log, + ) + get_logger("conftest").info( + "faulthandler thread dumps: every %ds → %s", interval_s, log_path, + ) + + +def _shutdown_faulthandler() -> None: + global _faulthandler_log + try: + faulthandler.cancel_dump_traceback_later() + except Exception: + pass + if _faulthandler_log is not None: + try: + _faulthandler_log.close() + finally: + _faulthandler_log = None def _extract_summary_details(test_report) -> dict[str, str | int | None]: @@ -129,6 +170,8 @@ def pytest_configure(config): reports_dir.mkdir(parents=True, exist_ok=True) + _install_faulthandler(reports_dir) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") if not hasattr(config.option, "xmlpath") or not config.option.xmlpath: @@ -206,6 +249,7 @@ def pytest_configure_node(node): def pytest_unconfigure(config): """Cleanup shared counter.""" + _shutdown_faulthandler() set_shared_pending_counter(None) globals()["_bs_pending_counter"] = None global _counter_manager @@ -247,10 +291,8 @@ def pytest_runtest_setup(item): def pytest_collection_modifyitems(config, items): """Automatically add single_device marker to tests with device_count(1).""" for item in items: - # Check if test has device_count marker with value 1 device_count_marker = item.get_closest_marker("device_count") if device_count_marker: - # Extract count from marker args or kwargs count = None if device_count_marker.args: count = device_count_marker.args[0] @@ -258,8 +300,6 @@ def pytest_collection_modifyitems(config, items): count = device_count_marker.kwargs["count"] elif "value" in device_count_marker.kwargs: count = device_count_marker.kwargs["value"] - - # If count is 1, add single_device marker if count == 1: item.add_marker(pytest.mark.single_device) diff --git a/test/e2e_appium/core/device_context.py b/test/e2e_appium/core/device_context.py index fe51227c43..d81593bc1f 100644 --- a/test/e2e_appium/core/device_context.py +++ b/test/e2e_appium/core/device_context.py @@ -1,4 +1,5 @@ from typing import Any +import time from appium.webdriver.webdriver import WebDriver @@ -122,48 +123,34 @@ class DeviceContext: def capture_profile_link(self) -> str | None: """Capture the user's profile link. - On Android mobile the "Copy link to profile" action - (``userStatusCopyLinkAction``) is permanently disabled in QML - (``enabled: !SQUtils.Utils.isMobile``). We still attempt it - briefly (1 attempt, 2s timeout) because the profile popup needs - those Appium driver interactions to render in the accessibility - tree. Then we fall back to the mobile "Invite contacts" path. + 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. Returns the captured link if successful, otherwise None. """ from pages.app import App - from utils.exceptions import ElementInteractionError self.logger.info("Capturing profile link for device %s", self.device_id) main_app = App(self.driver) - # Try the quick clipboard copy — this also opens the profile - # popup. On mobile the copy action is disabled, so this fails - # fast. The important side effect is that the popup renders - # during the brief interaction attempt. - profile_link = None - try: - profile_link = main_app.copy_profile_link_from_menu(timeout=2) - except (ElementInteractionError, Exception) as exc: - self.logger.debug("Profile menu copy not available (expected on mobile): %s", exc) + self.logger.info("Using Messages-panel shareProfileButton path for profile link") + profile_link = self._capture_via_messages_panel(main_app) - if profile_link: - self.logger.info("Profile link captured via clipboard: %s", profile_link) - self.set_state("profile_link", profile_link) - if self.user: - self.user.profile_link = profile_link - return profile_link - - # The profile popup should still be open from the copy attempt. - # Use the mobile-only "Invite contacts" path. - self.logger.info("Using Invite contacts path for profile link") - profile_link = self._capture_via_settings(main_app) - - # Re-activate app and verify the main UI is usable. # The overlay cleanup uses driver.back() which can over-navigate - # and send the app to the background on Android. + # on Android — _restore_main_ui foregrounds the app again. self._restore_main_ui(main_app) + # _restore_main_ui's activate_app shake unsticks the drawer's + # gesture handler, so a second whole-path retry materially helps + # if outer-5-retries inside the first call exhausted. + if not profile_link: + self.logger.warning( + "First capture_profile_link pass failed — retrying after activate_app()" + ) + profile_link = self._capture_via_messages_panel(main_app) + self._restore_main_ui(main_app) + if not profile_link: self.logger.error("All profile-link capture paths failed") return None @@ -176,6 +163,146 @@ class DeviceContext: return profile_link + 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. @@ -198,7 +325,7 @@ class DeviceContext: try: invite_visible = app.is_element_visible(INVITE_ACTION, timeout=2) if invite_visible: - # Profile popup already open from a previous attempt. + # Profile popup is already open (e.g. retry after partial failure). overlays_to_dismiss = 1 else: try: diff --git a/test/e2e_appium/locators/messaging/chat_locators.py b/test/e2e_appium/locators/messaging/chat_locators.py index 4a0ecf8f76..5ae149d3c1 100644 --- a/test/e2e_appium/locators/messaging/chat_locators.py +++ b/test/e2e_appium/locators/messaging/chat_locators.py @@ -11,25 +11,33 @@ class ChatLocators(BaseLocators): """ CHAT_LIST = BaseLocators.xpath("//*[contains(@resource-id,'ContactsColumnView_chatList')]") - CHAT_SEARCH_BOX = BaseLocators.content_desc_contains("tid:statusBaseInput") - CHAT_HEADER = BaseLocators.content_desc_contains( - "[tid:ContactsColumnView_MessagesHeadline]" - ) - TOOLBAR_BACK_BUTTON = BaseLocators.xpath( - "//android.widget.Button[@content-desc=' [tid:toolBarBackButton]']" - ) + CHAT_SEARCH_BOX = BaseLocators.tid("statusBaseInput") + CHAT_HEADER = BaseLocators.tid("ContactsColumnView_MessagesHeadline") + TOOLBAR_BACK_BUTTON = BaseLocators.tid("toolBarBackButton") MESSAGE_INPUT = BaseLocators.resource_id_contains("messageInputField") + # Tablet's BaseProxyPanel-based input sets objectName on QML buttons + # (matching `.NAME`); phone's StatusChatInputToolBar doesn't, so the + # same buttons surface as auto-named class identifiers + # (`StatusChatInputSendButton_QMLTYPE_NNNN`) or identical `.ChatIcon` + # resource-ids. Position [9] under StatusChatInputToolBar lands on + # the emoji button on phone (XPath document-order is off-by-one vs + # AT bounds order). + # TODO(upstream): drop the position fallback once + # StatusChatInputToolBar.qml sets objectName on each ChatIcon. SEND_BUTTON = BaseLocators.xpath( - "//*[contains(@resource-id,'statusChatInputSendButton')]" + "//*[contains(@content-desc, 'tid:statusChatInputSendButton') " + "or contains(@resource-id, '.statusChatInputSendButton')]" + " | " + "//*[contains(@resource-id, 'StatusChatInputSendButton')]" ) EMOJI_BUTTON = BaseLocators.xpath( - "//*[contains(@content-desc, '[tid:statusChatInputEmojiButton]') or " - "contains(@resource-id,'statusChatInputEmojiButton')]" - ) - COMMAND_BUTTON = BaseLocators.xpath( - "//*[contains(@content-desc, '[tid:statusChatInputCommandButton]') or " - "contains(@resource-id,'statusChatInputCommandButton')]" + "//*[contains(@content-desc, 'tid:statusChatInputEmojiButton') " + "or contains(@resource-id, '.statusChatInputEmojiButton')]" + " | " + "(//*[contains(@resource-id,'StatusChatInputToolBar')]" + "//*[contains(@resource-id, '.ChatIcon')])[9]" ) + COMMAND_BUTTON = BaseLocators.tid("statusChatInputCommandButton") CHAT_MORE_OPTIONS_BUTTON = BaseLocators.resource_id_contains("chatToolbarMoreOptionsButton") CHAT_MORE_OPTIONS_MENU = BaseLocators.resource_id_contains("moreOptionsContextMenu") # Use the 1:1 chat variant (clearHistoryMenuItem) — not the group variant @@ -47,21 +55,14 @@ class ChatLocators(BaseLocators): CLOSE_CHAT_CONFIRM_BUTTON = BaseLocators.resource_id_contains( "deleteChatConfirmationDialogDeleteButton" ) - ADD_IMAGE_ACTION = BaseLocators.xpath( - "//*[contains(@content-desc, '[tid:chatCommandMenu_addImage]') or " - "contains(@resource-id,'chatCommandMenu_addImage')]" - ) + ADD_IMAGE_ACTION = BaseLocators.tid("chatCommandMenu_addImage") CHAT_LOG_VIEW = BaseLocators.xpath("//*[contains(@resource-id,'chatLogView')]") - INTRODUCE_SKIP_BUTTON = BaseLocators.content_desc_contains( - "[tid:introduceSkipStatusFlatButton]" - ) - BACKUP_SKIP_BUTTON = BaseLocators.content_desc_contains( - "[tid:backupMessageSkipStatusFlatButton]" - ) + INTRODUCE_SKIP_BUTTON = BaseLocators.tid("introduceSkipStatusFlatButton") + BACKUP_SKIP_BUTTON = BaseLocators.tid("backupMessageSkipStatusFlatButton") START_CHAT_BUTTON = BaseLocators.xpath( "//*[contains(@resource-id,'startChatButton')]" ) - + # First chat item in the list (for open_first_chat) FIRST_CHAT_ITEM = BaseLocators.xpath( "(//android.widget.Button[contains(@resource-id,'StatusDraggableListItem')])[1]" @@ -114,17 +115,21 @@ class ChatLocators(BaseLocators): escaped = content.replace('"', '\\"') return BaseLocators.xpath(f"//*[contains(@content-desc,\"{escaped}\")]") - # Reply mode indicator - when replying, there's a reply preview bar - # QML: StatusChatInputReplyArea has objectName "statusChatInputReplyArea" - # and Accessible.name "Replying to {userName}" - REPLY_PREVIEW = BaseLocators.resource_id_contains("statusChatInputReplyArea") - REPLY_CLOSE_BUTTON = BaseLocators.resource_id_contains("replyAreaCloseButton") - REPLY_DETAILS = BaseLocators.xpath( - "//*[contains(@content-desc, '[tid:StatusMessage_replyDetails]') or " - "contains(@resource-id,'StatusMessage_replyDetails')]" + # Reply preview bar shown above the chat input when replying. + # QML: StatusChatInputReplyArea (objectName "statusChatInputReplyArea") + # on the tablet input; phone surfaces the auto-named class + # "StatusChatInputReplyPanel_QMLTYPE_NNNN". Union handles both; + # see EMOJI_BUTTON for the form-factor rationale. + REPLY_PREVIEW = BaseLocators.xpath( + "//*[contains(@content-desc, 'tid:statusChatInputReplyArea') " + "or contains(@resource-id, '.statusChatInputReplyArea')]" + " | " + "//*[contains(@resource-id, 'StatusChatInputReplyPanel')]" ) + REPLY_CLOSE_BUTTON = BaseLocators.resource_id_contains("replyAreaCloseButton") + REPLY_DETAILS = BaseLocators.tid("StatusMessage_replyDetails") REPLY_CORNER = BaseLocators.resource_id_contains("statusMessageReplyCorner") - + @staticmethod def reply_preview_for_user(username: str) -> tuple: """Locator for reply preview showing we're replying to a specific user.""" @@ -141,7 +146,7 @@ class ChatLocators(BaseLocators): f"//*[contains(@content-desc,'{escaped}')]/ancestor::*" f"//*[contains(@resource-id,'statusMessageReplyCorner')]" ) - + # Edited message indicator - "(edited)" text appended to message # The "(edited)" text is part of the message content-desc @staticmethod @@ -152,12 +157,12 @@ class ChatLocators(BaseLocators): f"//android.widget.EditText[contains(@content-desc,'{escaped}') " f"and contains(@content-desc,'(edited)')]" ) - + # Pinned message indicator - shows "Pinned by" text # QML: StatusPinMessageDetails has objectName "statusPinMessageDetails" # and Accessible.name "{pinnedMsgInfoText} {pinnedBy}" PINNED_INDICATOR = BaseLocators.resource_id_contains("statusPinMessageDetails") - + @staticmethod def pinned_indicator_by_user(username: str) -> tuple: """Locator for pinned indicator showing who pinned the message.""" @@ -165,7 +170,7 @@ class ChatLocators(BaseLocators): f"//*[contains(@resource-id,'statusPinMessageDetails')]" f"[contains(@content-desc,'{username}')]" ) - + @staticmethod def message_pinned_indicator(content: str) -> tuple: """Locator for pinned indicator near a specific message.""" @@ -174,19 +179,19 @@ class ChatLocators(BaseLocators): f"//*[contains(@content-desc,'{escaped}')]/ancestor::*" f"//*[contains(@resource-id,'statusPinMessageDetails')]" ) - + # Reaction on message - emoji reactions shown below the message # QML: StatusMessageEmojiReactions has objectName "statusMessageEmojiReactions" # Each reaction button has objectName "messageReaction_{emoji}" and Accessible.name "{emoji}" MESSAGE_REACTIONS_ROW = BaseLocators.resource_id_contains("statusMessageEmojiReactions") - + @staticmethod def reaction_on_message(emoji_code: str) -> tuple: """Locator for a reaction emoji displayed on a message (not in context menu). - + The reaction button has objectName "messageReaction_{emoji}" which maps to resource-id, and Accessible.name set to the emoji hex code (content-desc). - + Args: emoji_code: Unicode hex code (e.g., '1f600' for 😀) """ diff --git a/test/e2e_appium/pages/app.py b/test/e2e_appium/pages/app.py index 895465c5cf..ca6983b5cb 100644 --- a/test/e2e_appium/pages/app.py +++ b/test/e2e_appium/pages/app.py @@ -41,8 +41,7 @@ class App(BasePage): return "unknown" def click_settings_left_nav(self) -> bool: - self._ensure_main_nav_visible() - return self._click_nav_item(self.locators.LEFT_NAV_SETTINGS) + return self.click_settings_button() def click_messages_button(self) -> bool: self.logger.info("Clicking Messages button") @@ -70,38 +69,82 @@ class App(BasePage): self._ensure_main_nav_visible() return self._click_nav_item(self.locators.LEFT_NAV_MARKET) - def _click_nav_item(self, locator: tuple, timeout: int = 10) -> bool: - """Click a nav-bar item and, in portrait mode, wait for the drawer to close. + def _click_nav_item( + self, + locator: tuple, + timeout: int = 10, + strategy: str = "w3c", + ) -> bool: + # Branch on app layout (side-nav visible?), not device orientation — + # wide tablets in portrait still render the side-nav which never + # disappears, so the drawer-close wait below would never succeed. + if self.is_element_visible(self.locators.LEFT_NAV_ANY, timeout=1): + return self.safe_click(locator, timeout=timeout, max_attempts=2) - After the click the PrimaryNavSidebar drawer plays a close animation. - If the caller checks for the target section immediately it may not be - visible yet. This helper waits for the nav bar to disappear before - returning. - """ - clicked = self.safe_click(locator, timeout=timeout, max_attempts=2) - if not clicked: - return False + for attempt in range(1, 4): + el = self.find_element_safe(locator, timeout=timeout) + if el is None: + self.logger.warning( + "Nav item not found on attempt %d", attempt + ) + self.dump_page_source(f"nav_item_not_found_a{attempt}") + # Try re-opening the drawer in case it closed between + # _ensure_main_nav_visible and the lookup. + if attempt < 3: + self._ensure_main_nav_visible() + time.sleep(0.5) + continue + return False + rect = el.rect + cx = int(rect["x"] + rect["width"] / 2) + cy = int(rect["y"] + rect["height"] / 2) - if self.is_portrait_mode(): - # Wait for the drawer to close — nav items should disappear - self.wait_for_invisibility(self.locators.LEFT_NAV_ANY, timeout=5) - # Allow destination page to begin rendering after drawer animation - time.sleep(0.5) + try: + if strategy == "native": + self.logger.info( + "Nav-item mobile:clickGesture attempt %d at (%d,%d)", + attempt, cx, cy, + ) + self.driver.execute_script( + "mobile: clickGesture", {"elementId": el.id}, + ) + else: + self.logger.info( + "Nav-item W3C-pointer click attempt %d at (%d,%d)", + attempt, cx, cy, + ) + if not self.gestures.tap(cx, cy): + raise RuntimeError("gestures.tap returned False") + except Exception as exc: + self.logger.warning( + "Nav-item click failed on attempt %d: %s", attempt, exc + ) + continue - return True + if self.wait_for_invisibility(self.locators.LEFT_NAV_ANY, timeout=5): + # Drawer closed → tap registered. Allow destination page to + # begin rendering after drawer animation. + time.sleep(0.5) + return True + + self.logger.warning( + "Drawer still open after nav-item click attempt %d — retrying", + attempt, + ) + + return False def _ensure_main_nav_visible(self) -> bool: """Ensure the left navigation bar is visible. - In landscape the nav bar is always visible. In portrait it is a - drawer that slides from the left edge. This method first presses - back buttons to unwind deep navigation, then swipes from the left - edge to open the drawer. + Detect layout by side-nav visibility — don't trust device orientation; + the app's QML picks layout based on width, so wide tablets in portrait + still get the always-visible side-nav. """ if self.is_element_visible(self.locators.LEFT_NAV_SETTINGS, timeout=2): return True - if not self.is_portrait_mode(): + if self.is_element_visible(self.locators.LEFT_NAV_ANY, timeout=1): return self.is_element_visible(self.locators.LEFT_NAV_SETTINGS, timeout=5) # Phase 1: unwind deep navigation stack via back button @@ -211,8 +254,89 @@ class App(BasePage): if self.active_section() == "settings": self.logger.info("Already in Settings section — skipping nav") return True - self._ensure_main_nav_visible() - return self._click_nav_item(self.locators.LEFT_NAV_SETTINGS) + from locators.settings.settings_locators import SettingsLocators + settings_locators = SettingsLocators() + return self._click_drawer_nav_with_verify( + nav_locator=self.locators.LEFT_NAV_SETTINGS, + landmark_locator=settings_locators.PROFILE_MENU_ITEM, + nav_name="Settings", + ) + + def _click_drawer_nav_with_verify( + self, + nav_locator: tuple, + landmark_locator: tuple, + nav_name: str, + ) -> bool: + """Drawer nav click + landmark verify, retrying with strategy variation. + + On BS portrait the nav-item tap can close the drawer without firing + onClicked. We verify by destination-page landmark and retry, varying + strategy so a deterministic gesture race doesn't pin us at the same + failure mode. ``activate_app()`` between attempts unsticks the + drawer's gesture handler. + """ + # Defensive armour for phone-portrait sessions; on the tablet gate + # device, attempt 1 (native) reliably wins and the rest is dormant. + strategies = ["native", "native", "w3c", "w3c"] + pkg = self.driver.capabilities.get("appPackage") or "app.status.mobile" + + # On a second call within ~1min of a successful first, the + # drawer's gesture handler stays wedged and consumes taps + # without firing onClicked. activate_app shakes it loose. + def _shake_app(): + try: + self.driver.activate_app(pkg) + except Exception as exc: + self.logger.debug("activate_app suppressed: %s", exc) + + # BACK closes the drawer cleanly when activate_app's foregrounding + # alone fails to unstick a wedged gesture handler. + def _reset_drawer_state(): + try: + self.driver.press_keycode(4) # KEYCODE_BACK + except Exception as exc: + self.logger.debug("press_keycode(BACK) suppressed: %s", exc) + time.sleep(0.5) + + _shake_app() + time.sleep(0.6) # let activate_app foregrounding settle + + slug = nav_name.lower().replace(" ", "_") + for attempt in range(1, len(strategies) + 1): + if attempt > 1: + _reset_drawer_state() + _shake_app() + time.sleep(1.0) # longer settle on retry — the drawer + # state we're recovering from is already wedged + + self._ensure_main_nav_visible() + # Drawer slide-in keeps animating for ~200-400ms after the + # locator is in the AT tree; mid-animation taps land on stale + # bounds and trigger CloseOnPressOutside instead of onClicked. + time.sleep(0.6) + strategy = strategies[attempt - 1] + if not self._click_nav_item(nav_locator, strategy=strategy): + self.logger.warning( + "click_%s_button: nav-item click did not register on " + "attempt %d (strategy=%s)", slug, attempt, strategy, + ) + self.dump_page_source(f"{slug}_nav_no_click_a{attempt}_{strategy}") + self.take_screenshot(f"{slug}_nav_no_click_a{attempt}_{strategy}") + continue + # Brief settle before the 15s landmark check — Qt layout + # sometimes lags a frame behind the SwipeView transition. + time.sleep(0.5) + if self.is_element_visible(landmark_locator, timeout=15): + return True + self.logger.warning( + "click_%s_button: drawer closed but %s page not " + "visible on attempt %d (strategy=%s)", + slug, nav_name, attempt, strategy, + ) + self.dump_page_source(f"{slug}_drawer_closed_no_page_a{attempt}_{strategy}") + self.take_screenshot(f"{slug}_drawer_closed_no_page_a{attempt}_{strategy}") + return False def open_profile_menu(self) -> bool: self.logger.info("Opening profile menu from main navigation") @@ -297,8 +421,8 @@ class App(BasePage): def _is_toast_stable(self, duration: float) -> bool: """Check if toast remains visible for the specified duration.""" - end_time = time.time() + duration - while time.time() < end_time: + endtime = time.time() + duration + while time.time() < endtime: if not self.is_element_visible(self.locators.ANY_TOAST, timeout=0.1): return False time.sleep(0.05) diff --git a/test/e2e_appium/pages/onboarding/welcome_back_page.py b/test/e2e_appium/pages/onboarding/welcome_back_page.py index 623590be86..9bac2f46a2 100644 --- a/test/e2e_appium/pages/onboarding/welcome_back_page.py +++ b/test/e2e_appium/pages/onboarding/welcome_back_page.py @@ -37,7 +37,7 @@ class WelcomeBackPage(BasePage): return False if not self.qt_safe_input( - self.locators.PASSWORD_INPUT, password, verify=False + self.locators.PASSWORD_INPUT, password, verify=True ): self.logger.error("Password input failed on attempt %s", attempt) return False @@ -79,7 +79,10 @@ class WelcomeBackPage(BasePage): except Exception: pass - def _focus_password_field(self, retries: int = 5, wait_between: float = 2.0) -> bool: + def _focus_password_field(self, retries: int = 3, wait_between: float = 1.5) -> bool: + # Qt accessibility doesn't reliably expose `focused` for QML TextInput, + # so we don't gate on is_focused. Tap-and-trust; qt_safe_input verifies + # input actually lands. for attempt in range(retries): overlay = self.find_element_safe( self.locators.PASSWORD_INPUT_OVERLAY, timeout=2 @@ -98,17 +101,10 @@ class WelcomeBackPage(BasePage): time.sleep(wait_between) field = self.find_element_safe(self.locators.PASSWORD_INPUT, timeout=3) - if not field: + if not field or not ElementStateChecker.is_displayed(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.5) @@ -117,16 +113,13 @@ class WelcomeBackPage(BasePage): 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) + continue time.sleep(wait_between) + return True - 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") + self.logger.warning("Unable to find/tap password input on welcome back screen") return False def _wait_for_login_transition(self, timeout: int = 10) -> bool: diff --git a/test/e2e_appium/services/app_initialization_manager.py b/test/e2e_appium/services/app_initialization_manager.py index c74062e28f..e1bc922226 100644 --- a/test/e2e_appium/services/app_initialization_manager.py +++ b/test/e2e_appium/services/app_initialization_manager.py @@ -141,15 +141,17 @@ class AppInitializationManager: return False def _get_safe_tap_coordinates(self) -> tuple: - """Centre of screen — portrait/landscape safe, away from the - drawer swipe handle and system edge-gesture zones. + """Upper-right area — keeps the activation tap clear of common + interactive controls (account selector dropdowns near centre, drawer + swipe handle on the left edge) while staying well inside the screen + bounds (away from system edge-gesture zones). """ try: size = self.driver.get_window_size() - return (int(size["width"] * 0.5), int(size["height"] * 0.5)) + return (int(size["width"] * 0.9), int(size["height"] * 0.1)) except Exception: self.logger.warning("⚠ Could not get window size; using fallback coords") - return (540, 1200) # typical 1080×2400 portrait phone + return (970, 240) # typical 1080×2400 portrait phone, upper-right def _wait_for_ui_response( self, timeout: int = 5, poll_interval: float = 0.5 diff --git a/test/e2e_appium/tests/messaging/conftest.py b/test/e2e_appium/tests/messaging/conftest.py index 30acdc54c0..8c8111a494 100644 --- a/test/e2e_appium/tests/messaging/conftest.py +++ b/test/e2e_appium/tests/messaging/conftest.py @@ -1,15 +1,26 @@ -"""Module-level fixtures for messaging tests. +"""Session-level fixtures for messaging tests. Provides shared session setup for tests that require established contacts. -This avoids re-running the contact establishment flow for each test function. +The contact establishment flow runs once per pytest session, then all messaging +tests across all messaging-test modules share the same pair of onboarded +devices and established chat. This saves ~7-8 min of redundant setup per +additional messaging module compared to module scope. -Note on pytest-xdist: Module-scoped fixtures are per-worker-per-module, not global. -With -n=5, each worker that runs tests from this module will create its own -established_chat session. This is expected behavior for module scope. +State pollution between tests is managed by each test cleaning up its own UI +state (e.g. ``MessageContextMenuPage.dismiss`` uses Android back-button to avoid +mis-tapping the chat header). If session-wide pollution surfaces, an autouse +``_reset_chat_view`` fixture is the next safety net. + +Note on pytest-xdist: Session-scoped fixtures are per-worker. With -n=5, each +worker that runs messaging tests creates its own established_chat session. For +2-device tests on Pi local (only 2 phones available), xdist effectively +serialises them so a single fixture is reused across the whole worker. """ from __future__ import annotations +import threading + from dataclasses import dataclass import pytest @@ -19,11 +30,59 @@ from config.logging_config import get_logger from core.device_context import DeviceContext from core.multi_device_context import MultiDeviceContext from core.session_pool import PoolConfig, SessionPool +from utils.chat_state import ensure_chat_visible from utils.contact_helpers import establish_contact from utils.generators import generate_account_name logger = get_logger("messaging_conftest") + +class _SessionKeepAlive: + """Polls ``driver.orientation`` every ``interval_s`` from a daemon + thread to keep idle BS sessions alive across our 300s cross-device + waits (``appium:newCommandTimeout`` is coerced by BS). + + Selenium WebDriver isn't formally thread-safe; we only fire while the + main thread is blocked on the OTHER device, so concurrent commands on + the same driver are structurally avoided. + + TODO(upstream): drop once BS honours ``newCommandTimeout``. + """ + + def __init__(self, driver, label: str = "?", interval_s: int = 30) -> None: + self.driver = driver + self.label = label + self.interval_s = interval_s + self._stop = threading.Event() + self._thread: threading.Thread | None = None + + def start(self) -> None: + if self._thread is not None: + return + self._thread = threading.Thread( + target=self._run, name=f"keepalive-{self.label}", daemon=True + ) + self._thread.start() + logger.info("Started keep-alive heartbeat for %s (every %ds)", self.label, self.interval_s) + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=5) + logger.debug("Stopped keep-alive heartbeat for %s", self.label) + + def _run(self) -> None: + while not self._stop.wait(self.interval_s): + try: + _ = self.driver.orientation + except Exception as exc: + logger.warning( + "Keep-alive ping failed for %s: %s — stopping heartbeat", + self.label, exc, + ) + return + + # Track test outcomes at module level for BrowserStack status reporting _module_test_failures: dict[str, list[str]] = {} _module_test_skipped: dict[str, list[str]] = {} @@ -37,21 +96,21 @@ _module_pools = [] @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): """Track test outcomes for BrowserStack status reporting. - + This hook runs after each test phase (setup, call, teardown) and records outcomes to module-level tracking dicts. - + Note: Page dump capture on failure is handled by the main conftest.py hook. """ outcome = yield rep = outcome.get_result() - + # Only track test outcomes from the call phase (actual test execution) if rep.when != "call": return - + module_name = item.module.__name__ if hasattr(item, "module") else "unknown" - + if rep.failed: if module_name not in _module_test_failures: _module_test_failures[module_name] = [] @@ -69,7 +128,7 @@ def pytest_runtest_makereport(item, call): @dataclass class EstablishedChatContext: """Context for tests that require an established chat between two users. - + Attributes: primary: The device that sent the contact request. secondary: The device that accepted the contact request. @@ -82,12 +141,12 @@ class EstablishedChatContext: primary_suffix: str secondary_suffix: str multi_ctx: MultiDeviceContext - + @property def primary_driver(self): return self.primary.driver - - @property + + @property def secondary_driver(self): return self.secondary.driver @@ -112,22 +171,23 @@ async def _establish_contact( def _report_browserstack_status(pool: SessionPool, status: str, reason: str | None = None) -> None: """Report session status to BrowserStack for all sessions in the pool. - - This is needed for module-scoped fixtures because they bypass the standard - conftest.py pytest_runtest_makereport hook that normally reports status. + + Needed because the session-scoped ``established_chat`` fixture bypasses + the standard ``conftest.py:pytest_runtest_makereport`` hook that normally + reports per-test status. """ if not pool or pool.session_count == 0: return - + for device_name in pool.device_names: session_manager = pool.get_session_manager(device_name) driver = pool.get_driver(device_name) - + if not session_manager or not driver: continue - + session_id = getattr(driver, "session_id", None) - + # Try to report via driver first (executor command) try: session_manager.provider.report_session_status(driver, status, reason) @@ -135,7 +195,7 @@ def _report_browserstack_status(pool: SessionPool, status: str, reason: str | No continue except Exception as e: logger.debug("Executor status report failed for %s: %s", device_name, e) - + # Fall back to REST API if session_id: try: @@ -190,17 +250,22 @@ async def _setup_established_chat( ) -@pytest_asyncio.fixture(scope="module") +@pytest_asyncio.fixture(scope="session") async def established_chat(request, test_environment) -> EstablishedChatContext: - """Module-scoped fixture providing two devices with an established chat. - - This runs the contact establishment flow once per module, then all tests - in the module share the same session with contacts already connected. + """Session-scoped fixture providing two devices with an established chat. + + This runs the contact establishment flow once per pytest session, then all + messaging tests across modules share the same session with contacts already + connected. Saves ~7-8 min per additional messaging module vs module scope. + + State isolation between tests is the responsibility of each test (clean up + overlays, dismiss context menus). If session-wide pollution surfaces, the + next defence is an autouse ``_reset_chat_view`` fixture. If the setup fails (e.g. BrowserStack connection drop, biometrics prompt timing, device allocation, accessibility tree blocking), it cleans up and retries once with fresh sessions. - + Usage: class TestMessageContextMenu: @pytest.fixture(autouse=True) @@ -208,25 +273,55 @@ async def established_chat(request, test_environment) -> EstablishedChatContext: self.ctx = established_chat self.primary = established_chat.primary self.driver = self.primary.driver - + async def test_context_menu(self): chat_page = ChatPage(self.driver) ... """ global _module_pools - logger.info("Setting up module-scoped established_chat fixture") + logger.info("Setting up session-scoped established_chat fixture") pool = None ctx = None setup_failed = False max_attempts = 2 last_error: BaseException | None = None - + for attempt in range(1, max_attempts + 1): try: + # For local environments, assign each session to a different + # device from the YAML matrix (set via local.local.yaml overlay). + device_overrides = None + if test_environment == "local": + try: + from core.config_manager import ConfigurationManager + cfg_mgr = ConfigurationManager() + env_cfg = cfg_mgr.load_environment("local") + matrix_devices = list(env_cfg.devices.values()) + if len(matrix_devices) >= 2: + defaults = env_cfg.device_defaults.get("capabilities", {}) + device_overrides = [] + for i in range(2): + device = matrix_devices[i] + override = {"capabilities": device.merged_capabilities(defaults)} + if device.provider_overrides: + override.update(device.provider_overrides) + device_overrides.append(override) + for idx, ov in enumerate(device_overrides): + caps = ov.get("capabilities", {}) + logger.info( + "Local device override %d: udid=%s server_url=%s", + idx, + caps.get("appium:udid", "?"), + ov.get("server_url", "default"), + ) + except Exception as exc: + logger.warning("Failed to build local device overrides: %s", exc) + pool_config = PoolConfig.from_environment( test_environment, parallel=True, + device_overrides=device_overrides, ) pool = SessionPool(config=pool_config) @@ -269,14 +364,35 @@ async def established_chat(request, test_environment) -> EstablishedChatContext: setup_failed = True raise last_error # type: ignore[misc] + # Start keep-alive heartbeats on both device sessions to prevent + # BrowserStack idle-timeout death during long cross-device sync waits. + # See _SessionKeepAlive docstring for the failure mode. + keepalives: list[_SessionKeepAlive] = [] + try: + if ctx and ctx.primary and ctx.secondary: + keepalives = [ + _SessionKeepAlive(ctx.primary.driver, label="primary"), + _SessionKeepAlive(ctx.secondary.driver, label="secondary"), + ] + for ka in keepalives: + ka.start() + except Exception as exc: + logger.warning("Could not start keep-alive heartbeats: %s", exc) + try: yield ctx - + except Exception: setup_failed = True raise - + finally: + # Stop heartbeats first so they don't race with cleanup + for ka in keepalives: + try: + ka.stop() + except Exception: + pass # Report status to BrowserStack before cleanup if pool: if setup_failed: @@ -286,7 +402,7 @@ async def established_chat(request, test_environment) -> EstablishedChatContext: failed_tests = _module_test_failures.get(module_name, []) skipped_tests = _module_test_skipped.get(module_name, []) passed_tests = _module_test_passed.get(module_name, []) - + if failed_tests: failure_count = len(failed_tests) reason = f"{failure_count} test(s) failed" @@ -306,16 +422,45 @@ async def established_chat(request, test_environment) -> EstablishedChatContext: reason = f"All {passed_count} test(s) passed" _report_browserstack_status(pool, "passed", reason) logger.info("Reported 'passed' to BrowserStack: %s", reason) - + for tracking_dict in (_module_test_failures, _module_test_skipped, _module_test_passed): if module_name in tracking_dict: del tracking_dict[module_name] - - logger.info("Cleaning up module-scoped sessions") + + logger.info("Cleaning up session-scoped fixture sessions") try: await pool.cleanup() except Exception as e: logger.warning("Cleanup error (non-fatal): %s", e) - + if pool in _module_pools: _module_pools.remove(pool) + + +@pytest.fixture +def chat_ready(established_chat) -> EstablishedChatContext: + """Function-scoped state guarantee on top of session-scoped infrastructure. + + Ensures both devices have the chat with their peer open and message input + visible before the test runs. Recovers from any state the previous test + left (chat closed on either side, scrolled off, etc.) so tests don't + need to know what came before them — and so xdist dispatch order doesn't + matter. + + Recovery is via ``utils.chat_state.ensure_chat_visible``; see there for + the strategy ladder. + + Sync rather than async-with-``asyncio.to_thread``: pytest-asyncio's + function-scoped event loops shut down the default executor on teardown, + which can hang for minutes if a thread-pool worker is mid-Selenium-call. + Calling ``ensure_chat_visible`` synchronously sequentially keeps execution + on the main thread; recovery on both devices runs ~30s slower in the + worst case but doesn't leave executor threads to track. + """ + ctx = established_chat + primary_display = ctx.primary.user.display_name if ctx.primary.user else None + secondary_display = ctx.secondary.user.display_name if ctx.secondary.user else None + + ensure_chat_visible(ctx.primary, ctx.secondary_suffix, secondary_display) + ensure_chat_visible(ctx.secondary, ctx.primary_suffix, primary_display) + return ctx diff --git a/test/e2e_appium/tests/messaging/test_emoji_and_media.py b/test/e2e_appium/tests/messaging/test_emoji_and_media.py index 9976dd1d57..13506fea76 100644 --- a/test/e2e_appium/tests/messaging/test_emoji_and_media.py +++ b/test/e2e_appium/tests/messaging/test_emoji_and_media.py @@ -28,13 +28,13 @@ class TestEmojiAndMedia: logger = get_logger("TestEmojiAndMedia") @pytest.fixture(autouse=True) - def setup(self, established_chat): - self.ctx = established_chat - self.primary = established_chat.primary - self.secondary = established_chat.secondary - self.driver = established_chat.primary.driver - self.primary_suffix = established_chat.primary_suffix - self.secondary_suffix = established_chat.secondary_suffix + def setup(self, chat_ready): + self.ctx = chat_ready + self.primary = chat_ready.primary + self.secondary = chat_ready.secondary + self.driver = chat_ready.primary.driver + self.primary_suffix = chat_ready.primary_suffix + self.secondary_suffix = chat_ready.secondary_suffix def _ensure_in_chat(self) -> ChatPage: app = App(self.driver) @@ -57,7 +57,7 @@ class TestEmojiAndMedia: assert chat_page.open_chat_by_suffix( self.secondary_suffix, display_name=display_name, - timeout=15, + timeout=self.CROSS_DEVICE_TIMEOUT, ), "Failed to open chat by suffix" assert chat_page.wait_for_message_input(timeout=10), "Message input not ready" return chat_page @@ -85,6 +85,7 @@ class TestEmojiAndMedia: @pytest.mark.gate @pytest.mark.spec("SC-MTYP-04") + @pytest.mark.flaky(reruns=1, reruns_delay=5) async def test_emoji_received_cross_device(self) -> None: """Verify emoji message is delivered to the receiving device. @@ -114,7 +115,7 @@ class TestEmojiAndMedia: secondary_chat.open_chat_by_suffix( self.primary_suffix, display_name=display_name, - timeout=15, + timeout=self.CROSS_DEVICE_TIMEOUT, ) secondary_chat.wait_for_message_input(timeout=10) diff --git a/test/e2e_appium/tests/messaging/test_message_context_menu.py b/test/e2e_appium/tests/messaging/test_message_context_menu.py index 98c9386f0d..700f77fcd5 100644 --- a/test/e2e_appium/tests/messaging/test_message_context_menu.py +++ b/test/e2e_appium/tests/messaging/test_message_context_menu.py @@ -5,8 +5,12 @@ Tests the long-press context menu on messages including: - Quick reactions - Reply, Edit, Delete, Copy, Pin actions -These tests share the ``established_chat`` fixture so every test operates -on the same onboarded device pair with contacts already set up. +Split into two classes so xdist's loadscope can parallelise: +- ``TestMessageContextMenuLocal``: tests that don't wait for cross-device sync +- ``TestMessageContextMenuCrossDevice``: tests that verify on both devices + +Both share the ``chat_ready`` fixture so every test starts with a usable chat +on both devices, regardless of what came before. """ import asyncio @@ -32,11 +36,12 @@ def _unique_message(prefix: str = "test") -> str: @pytest.mark.device_count(2) @pytest.mark.timeout(1200) @pytest.mark.flaky(reruns=1, reruns_delay=5) -class TestMessageContextMenu: - """Tests for message context menu interactions. +class _MessageContextMenuBase: + """Shared helpers + setup for context-menu test classes. - Uses the shared ``established_chat`` fixture; each test sends its own - unique message to operate on. + Underscore-prefixed so pytest doesn't collect it as a test class. + Tests live in the two subclasses below; this class only carries the + fixture wiring, navigation helpers, and shared constants. """ UI_TIMEOUT = 30 @@ -47,11 +52,11 @@ class TestMessageContextMenu: logger = get_logger("TestMessageContextMenu") @pytest.fixture(autouse=True) - def setup(self, established_chat): - """Auto-setup using the shared established_chat fixture.""" - self.ctx = established_chat - self.driver = established_chat.primary.driver - self.device = established_chat.primary + def setup(self, chat_ready): + """Auto-setup using the chat_ready fixture (recovers state per test).""" + self.ctx = chat_ready + self.driver = chat_ready.primary.driver + self.device = chat_ready.primary @asynccontextmanager async def step(self, description: str): @@ -170,6 +175,15 @@ class TestMessageContextMenu: return secondary_chat + +class TestMessageContextMenuLocal(_MessageContextMenuBase): + """Context-menu actions that don't require cross-device sync. + + These tests interact with the menu and verify primary-only state, so they + finish in seconds. Faster than the cross-device class — schedules well + on either xdist worker. + """ + @pytest.mark.gate @pytest.mark.smoke async def test_context_menu_own_message_actions(self) -> None: @@ -245,6 +259,15 @@ class TestMessageContextMenu: ) # Note: Clipboard verification would require platform-specific APIs + +class TestMessageContextMenuCrossDevice(_MessageContextMenuBase): + """Context-menu actions that verify cross-device sync. + + These tests assert on both primary and secondary, so each pays the + cross-device delivery wait (180–300s). Heavier scope — runs in parallel + with the local class on the other xdist worker. + """ + @pytest.mark.smoke @pytest.mark.xfail(reason="status-go#7393: cross-device delivery unreliable", strict=False) @pytest.mark.spec("SC-MACT-03") diff --git a/test/e2e_appium/tests/messaging/test_z_chat_management.py b/test/e2e_appium/tests/messaging/test_z_chat_management.py index e4621a2ac1..65c87577d1 100644 --- a/test/e2e_appium/tests/messaging/test_z_chat_management.py +++ b/test/e2e_appium/tests/messaging/test_z_chat_management.py @@ -40,10 +40,10 @@ class TestChatManagement: logger = get_logger("TestChatManagement") @pytest.fixture(autouse=True) - def setup(self, established_chat): - self.ctx = established_chat - self.driver = established_chat.primary.driver - self.device = established_chat.primary + def setup(self, chat_ready): + self.ctx = chat_ready + self.driver = chat_ready.primary.driver + self.device = chat_ready.primary @asynccontextmanager async def step(self, description: str): diff --git a/test/e2e_appium/tests/test_ios_poc.py b/test/e2e_appium/tests/test_ios_poc.py index 686e2b704d..86095063ca 100644 --- a/test/e2e_appium/tests/test_ios_poc.py +++ b/test/e2e_appium/tests/test_ios_poc.py @@ -29,7 +29,6 @@ from utils.platform import get_platform @pytest.mark.ios -@pytest.mark.smoke @pytest.mark.device_count(1) class TestIOSOnboarding(StepMixin): """Run the full onboarding fixture on iOS and verify wallet landing. diff --git a/test/e2e_appium/utils/chat_state.py b/test/e2e_appium/utils/chat_state.py new file mode 100644 index 0000000000..6fa89dc685 --- /dev/null +++ b/test/e2e_appium/utils/chat_state.py @@ -0,0 +1,81 @@ +"""State recovery helpers for the function-scoped ``chat_ready`` fixture. + +Lets the session-scoped ``established_chat`` fixture stay expensive but +order-agnostic: tests that close or clear the chat don't poison the next +test, since recovery via the contacts list re-opens the missing row. +""" + +from config.logging_config import get_logger +from core.device_context import DeviceContext +from pages.app import App +from pages.messaging.chat_page import ChatPage +from pages.settings.settings_page import SettingsPage + +logger = get_logger("chat_state") + + +def ensure_chat_visible( + device: DeviceContext, + peer_suffix: str, + peer_display_name: str | None = None, +) -> None: + """Guarantee the chat with ``peer_suffix`` is open on ``device``. + + Tries (cheap → expensive): already-in-chat, Messages-tab nav, + open-from-chat-list-by-suffix, then Settings → Contacts → open-chat. + Raises ``RuntimeError`` with a diagnostic if all paths fail. + """ + driver = device.driver + app = App(driver) + chat_page = ChatPage(driver) + + if chat_page.wait_for_message_input(timeout=2): + return + + chat_page.dismiss_backup_prompt(timeout=2) + app.click_messages_button() + chat_page.dismiss_backup_prompt(timeout=2) + + if chat_page.wait_for_message_input(timeout=3): + return + + if chat_page.open_chat_by_suffix( + peer_suffix, display_name=peer_display_name, timeout=15 + ) and chat_page.wait_for_message_input(timeout=10): + return + + logger.info( + "Chat row missing for %s — recovering via Settings → Contacts", + peer_suffix, + ) + + if not app.click_settings_button(): + raise RuntimeError("Failed to open Settings during chat recovery") + + settings = SettingsPage(driver) + if not settings.is_loaded(timeout=10): + raise RuntimeError("Settings page did not load during chat recovery") + + # Reach Contacts via Messaging — the direct CONTACTS_MENU_ITEM locator + # (tid:2-MenuItem) is brittle (sparse menu indices vary by build), but the + # Messaging → Contacts path is what establish_contact uses successfully. + messaging = settings.open_messaging_settings() + if not messaging: + raise RuntimeError("Failed to open messaging settings during chat recovery") + + contacts = messaging.open_contacts() + if not contacts: + raise RuntimeError("Failed to open contacts during chat recovery") + + # ContactPanel.content-desc on an accepted contact carries the chat-key + # (``zQ3...{suffix}``), not the human display name — open_chat_with is + # named for display_name but does a substring match. Pass the suffix. + if not contacts.open_chat_with(peer_suffix): + raise RuntimeError( + f"Failed to open chat with contact suffix '{peer_suffix}' from contacts list" + ) + + if not chat_page.wait_for_message_input(timeout=15): + raise RuntimeError( + "Chat opened from contacts but message input not visible" + ) diff --git a/test/e2e_appium/utils/contact_helpers.py b/test/e2e_appium/utils/contact_helpers.py index 27e3db3f95..eabcb47f7c 100644 --- a/test/e2e_appium/utils/contact_helpers.py +++ b/test/e2e_appium/utils/contact_helpers.py @@ -14,6 +14,7 @@ from core.device_context import DeviceContext from pages.app import App from pages.messaging.chat_page import ChatPage from pages.settings.settings_page import SettingsPage +from utils.timeouts import cross_device_timeout logger = get_logger("contact_helpers") @@ -81,9 +82,10 @@ async def establish_contact( assert modal.send(), "Sender failed to send contact request" # Navigate sender back to messages - assert sender_app.click_messages_button(), "Sender failed to navigate to messages" sender_chat = ChatPage(sender.driver) sender_chat.dismiss_backup_prompt(timeout=4) + assert sender_app.click_messages_button(), "Sender failed to navigate to messages" + sender_chat.dismiss_backup_prompt(timeout=2) # Receiver accepts contact request receiver_app = App(receiver.driver) @@ -115,9 +117,10 @@ async def establish_contact( await asyncio.sleep(5) # Navigate receiver to messages - assert receiver_app.click_messages_button(), "Receiver failed to navigate to messages" receiver_chat = ChatPage(receiver.driver) receiver_chat.dismiss_backup_prompt(timeout=4) + assert receiver_app.click_messages_button(), "Receiver failed to navigate to messages" + receiver_chat.dismiss_backup_prompt(timeout=2) sender_display = sender.user.display_name if sender.user else None receiver_display = receiver.user.display_name if receiver.user else None @@ -143,6 +146,7 @@ async def establish_contact( # Wait for the chat on sender side — re-tap Messages to refresh the # list in case the P2P message arrived but the UI hasn't updated. logger.info("Sender waiting for DM from receiver") + sender_chat.dismiss_backup_prompt(timeout=2) assert sender_app.click_messages_button(), "Sender failed to refresh messages tab" sender_chat.dismiss_backup_prompt(timeout=2) sender_chat.dismiss_introduce_prompt(timeout=2) @@ -163,5 +167,26 @@ async def establish_contact( "Message input not ready on sender" ) + # Delivery gate — bidirectional (status-go#7393). + # The Waku filter subscription race means messages sent immediately after + # contact acceptance can be dropped in either direction. Verify both + # directions before yielding so tests don't run against a half-working session. + + # Direction 1: receiver → sender (setup message already sent above) + assert sender_chat.message_exists(setup_msg, timeout=cross_device_timeout()), ( + "Delivery gate failed: setup message from receiver not visible on sender. " + "Waku filter subscription may not have propagated yet." + ) + + # Direction 2: sender → receiver + ping_msg = f"Ping from {sender_suffix}" + assert sender_chat.send_message(ping_msg, timeout=15), ( + "Delivery gate failed: sender could not send ping message" + ) + assert receiver_chat.message_exists(ping_msg, timeout=cross_device_timeout()), ( + "Delivery gate failed: ping from sender not visible on receiver. " + "Waku filter subscription may not have propagated yet." + ) + logger.info("Contact established: %s <-> %s", sender_suffix, receiver_suffix) return sender_suffix, receiver_suffix, sender_chat_key, receiver_chat_key