mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 15:11:20 +00:00
e2e_appium: 1x1 messaging gate stability + maintenance
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 (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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user