e2e_appium: 1x1 messaging gate stability + maintenance

This commit is contained in:
Mag.
2026-05-11 07:10:52 +01:00
committed by GitHub
parent 6ed624300f
commit e7d709aed7
13 changed files with 756 additions and 191 deletions
+45 -5
View File
@@ -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)
+158 -31
View File
@@ -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:
@@ -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 😀)
"""
+151 -27
View File
@@ -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)
@@ -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:
@@ -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
+183 -38
View File
@@ -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
@@ -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)
@@ -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 (180300s). 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")
@@ -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):
-1
View File
@@ -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.
+81
View File
@@ -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"
)
+27 -2
View File
@@ -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