From baad98f70f9d0223672be4cd2159387c5af8efb6 Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Fri, 10 Apr 2026 11:26:34 +0200 Subject: [PATCH 1/2] test: harden shared Android UI tests (WPB-24675) --- .../criticalFlows/FileSharingBetweenTeams.kt | 3 +- .../criticalFlows/PersonalAccountLifeCycle.kt | 16 +- .../tests/core/pages/ConversationListPage.kt | 108 +++++++---- .../tests/core/pages/ConversationViewPage.kt | 33 ++++ .../tests/core/pages/RegistrationPage.kt | 118 ++++++++++-- .../android/tests/core/pages/SettingsPage.kt | 19 +- .../src/main/service/TestService.kt | 8 +- .../src/main/service/TestServiceHelper.kt | 173 +++++++++++++----- .../src/main/uiautomatorutils/UiWaitUtils.kt | 75 +++++++- 9 files changed, 444 insertions(+), 109 deletions(-) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt index 94031139e52..8cfcdb67e66 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/FileSharingBetweenTeams.kt @@ -42,6 +42,7 @@ import com.wire.android.tests.core.BaseUiTest import com.wire.android.tests.support.tags.Category import com.wire.android.tests.support.tags.TestCaseId import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed @RunWith(AndroidJUnit4::class) class FileSharingBetweenTeams : BaseUiTest() { @@ -169,7 +170,7 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Verify connection accepted toast and start a conversation with sender") { pages.connectedUserProfilePage.apply { - assertToastMessageIsDisplayed("Connection request accepted") + waitUntilToastIsDisplayed("Connection request accepted") clickStartConversationButton() } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt index 25b14d6e463..f7e586cc4f9 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/PersonalAccountLifeCycle.kt @@ -66,8 +66,8 @@ class PersonalAccountLifeCycle : BaseUiTest() { @After fun tearDown() { - teamOwner?.deleteTeam(backendClient) - personalUser?.deleteUser(backendClient) + teamOwner?.deleteTeam(backendClient) + personalUser?.deleteUser(backendClient) } @Suppress("CyclomaticComplexMethod", "LongMethod") @@ -181,7 +181,14 @@ class PersonalAccountLifeCycle : BaseUiTest() { tapConversationNameInConversationList(teamOwner?.name ?: "") } } - + // Wait for the personal 1:1 conversation to fully settle in MLS before sending. + // The 5s settle window specifically reduces intermittent test-service send flakes right after MLS transition. + step("Wait until personal 1:1 conversation is upgraded to MLS") { + pages.conversationViewPage.waitUntilConversationTurnsMls( + timeoutMs = 20_000, + settleAfterDetectedMs = 5_000 + ) + } step("Send message to team owner in 1:1 conversation") { pages.conversationViewPage.apply { typeMessageInInputField("Hello Team Owner") @@ -191,7 +198,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { } step("Receive message from team owner via backend in 1:1 conversation") { - testServiceHelper.userSendMessageToConversationObj( + testServiceHelper.userSendMessageToPersonalMlsConversation( "user1Name", "Hello to you too!", "Device1", @@ -199,6 +206,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { false ) + closeKeyboardIfOpened() pages.conversationViewPage.apply { assertReceivedMessageIsVisibleInCurrentConversation("Hello to you too!") } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index c32e4cc8a79..d872f913672 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -17,9 +17,12 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector import org.junit.Assert import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils @@ -45,17 +48,9 @@ data class ConversationListPage(private val device: UiDevice) { UiSelectorParams(text = conversationName) } private val startNewConversation = UiSelectorParams(description = "New. Start a new conversation") - private val backArrowButtonInsideSearchField = UiSelectorParams( - className = "android.view.View", - description = "Go back to add participants view" - ) - - private val closeNewConversationButton = UiSelectorParams( - description = "Close new conversation view" - ) - - private val userConversationNamePendingLabelString = UiSelectorParams(description = "pending approval of connection request") + private val userConversationNamePendingLabelSelector = + UiSelector().description("pending approval of connection request") fun assertConversationListVisible(): ConversationListPage { val heading = UiWaitUtils.waitElement(conversationListHeading) Assert.assertTrue( @@ -70,9 +65,39 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun clickSettingsButtonOnMenuEntry(): ConversationListPage { - UiWaitUtils.waitElement(settingsButton).click() - return this + fun clickSettingsButtonOnMenuEntry(timeoutMs: Long = 10_000): ConversationListPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastMenuClickAt = 0L + + while (SystemClock.uptimeMillis() < deadline) { + if (tryClickIfVisible(settingsButton)) { + return this + } + + val now = SystemClock.uptimeMillis() + if (now - lastMenuClickAt >= 600 && tryClickIfVisible(mainMenuButton)) { + lastMenuClickAt = now + device.waitForIdle(300) + } + + SystemClock.sleep(120) + } + + throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") + } + + private fun tryClickIfVisible(selector: UiSelectorParams): Boolean { + val element = UiWaitUtils.findElementOrNull(selector) ?: return false + return try { + if (!element.visibleBounds.isEmpty && element.isEnabled) { + element.click() + true + } else { + false + } + } catch (_: StaleObjectException) { + false + } } fun clickConversationsButtonOnMenuEntry(): ConversationListPage { @@ -153,14 +178,21 @@ data class ConversationListPage(private val device: UiDevice) { return this } - fun tapBackArrowButtonInsideSearchField(): ConversationListPage { - val button = UiWaitUtils.waitElement(backArrowButtonInsideSearchField) - button.click() - return this - } + fun clickCloseButtonOnNewConversationScreen(timeoutMs: Long = 5_000): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + val close = device.findObject( + UiSelector() + .className("android.view.View") + .description("Close new conversation view") + ) + + if (!close.waitForExists(timeoutMs)) { + throw AssertionError("Close button not found within ${timeoutMs}ms") + } + + close.click() - fun clickCloseButtonOnNewConversationScreen(): ConversationListPage { - UiWaitUtils.waitElement(closeNewConversationButton).click() return this } @@ -170,29 +202,43 @@ data class ConversationListPage(private val device: UiDevice) { return this } + @Suppress("ThrowsCount") fun assertConversationNameWithPendingStatusVisibleInConversationList(userName: String): ConversationListPage { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + // 1) Assert user name is visible try { - UiWaitUtils.waitElement(UiSelectorParams(text = userName)) - } catch (e: AssertionError) { + val userObj = device.findObject(UiSelector().text(userName)) + if (!userObj.waitForExists(10_000)) { + throw AssertionError("User '$userName' is not visible in the conversation list") + } + } catch (e: Throwable) { throw AssertionError("User '$userName' is not visible in the conversation list", e) } - // Assert the 'pending' badge is visible + + // 2) Assert the 'pending' badge is visible try { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - } catch (e: AssertionError) { + val pendingObj = device.findObject(userConversationNamePendingLabelSelector) + if (!pendingObj.waitForExists(10_000)) { + throw AssertionError("Pending status is not visible for user '$userName'") + } + } catch (e: Throwable) { throw AssertionError("Pending status is not visible for user '$userName'", e) } + return this } fun assertPendingStatusIsNoLongerVisible(): ConversationListPage { - val pending = runCatching { - UiWaitUtils.waitElement(userConversationNamePendingLabelString) - }.getOrNull() + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + UiWaitUtils.waitUntilElementGone( + device = device, + selector = userConversationNamePendingLabelSelector, + timeoutMillis = 10_000, + pollingInterval = 250 + ) - if (pending != null && !pending.visibleBounds.isEmpty) { - throw AssertionError("Pending status is still visible (expected it to be gone)") - } return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt index ebbbc7ea35e..25a9334670f 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationViewPage.kt @@ -17,6 +17,7 @@ */ package com.wire.android.tests.core.pages +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice @@ -71,6 +72,12 @@ data class ConversationViewPage(private val device: UiDevice) { private val selfDeletingMessageLabel = UiSelectorParams(description = " Self-deleting message") + private val mlsUpgradeMessageSelectors = listOf( + UiSelectorParams(textContains = "This conversation now uses the new Messaging"), + UiSelectorParams(textContains = "Layer Security (MLS) protocol"), + UiSelectorParams(textContains = "latest version of Wire on your devices") + ) + private fun selfDeleteOption(label: String): UiSelectorParams { return UiSelectorParams(text = label, className = "android.widget.TextView") } @@ -470,4 +477,30 @@ data class ConversationViewPage(private val device: UiDevice) { return this } + + fun waitUntilConversationTurnsMls( + timeoutMs: Long = 20_000, + settleAfterDetectedMs: Long = 0 + ): ConversationViewPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val mlsMarker = mlsUpgradeMessageSelectors + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty } + + if (mlsMarker != null) { + // MLS banner can appear slightly before the conversation is fully ready for a first outbound message. + if (settleAfterDetectedMs > 0) { + SystemClock.sleep(settleAfterDetectedMs) + } + return this + } + + SystemClock.sleep(200) + } + + throw AssertionError("MLS upgrade system message was not visible within ${timeoutMs}ms.") + } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt index 75c43aa77af..26b7fe58a31 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/RegistrationPage.kt @@ -18,8 +18,10 @@ package com.wire.android.tests.core.pages import androidx.test.espresso.matcher.ViewMatchers.assertThat +import android.os.SystemClock import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import org.hamcrest.CoreMatchers.`is` @@ -50,10 +52,11 @@ class RegistrationPage(private val device: UiDevice) { private val userNameHelpText = UiSelectorParams(textContains = "At least 2 character") private val editTextClass = By.clazz("android.widget.EditText") private val confirmButton = UiSelectorParams(text = "Confirm") - private val allowNotificationButton = - UiSelectorParams( - resourceId = "com.android.permissioncontroller:id/permission_allow_button" - ) + private val allowNotificationButtons = listOf( + UiSelectorParams(resourceId = "com.android.permissioncontroller:id/permission_allow_button"), + UiSelectorParams(text = "Allow") + ) + private val consentDialogTitle = UiSelectorParams(textContains = "Consent to share user data") private val declineButton = UiSelectorParams(text = "Decline") private val loginButtonGoneSelector = UiSelector().resourceId("loginButton") private val settingUpWireGoneSelector = UiSelector() @@ -69,15 +72,52 @@ class RegistrationPage(private val device: UiDevice) { } fun enterPersonalUserRegistrationEmail(email: String): RegistrationPage { - val emailIputfield = UiWaitUtils.waitElement(emailInputField) - emailIputfield.click() - emailIputfield.text = email - return this + repeat(3) { + try { + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).click() + UiWaitUtils.waitElement(emailInputField, timeoutMillis = 2_000).text = email + return this + } catch (_: StaleObjectException) { + SystemClock.sleep(150) + } catch (_: AssertionError) { + SystemClock.sleep(150) + } + } + + throw AssertionError("Could not enter registration email: email input field was unstable.") } - fun clickLoginButton(): RegistrationPage { - UiWaitUtils.waitElement(loginButton).click() - return this + @Suppress("NestedBlockDepth") + fun clickLoginButton(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastError: AssertionError? = null + + while (SystemClock.uptimeMillis() < deadline) { + try { + UiWaitUtils.waitElement(loginButton, timeoutMillis = 1_500).click() + return this + } catch (e: AssertionError) { + lastError = e + try { + val button = UiWaitUtils.findElementOrNull(loginButton) + if (button != null && !button.visibleBounds.isEmpty && button.isEnabled) { + button.click() + return this + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + } catch (_: StaleObjectException) { + // Retry with a freshly resolved node. + } + + SystemClock.sleep(200) + } + + throw AssertionError( + "Login button was not clickable within ${timeoutMs}ms.", + lastError + ) } fun clickCreateAccountButton(): RegistrationPage { @@ -158,13 +198,12 @@ class RegistrationPage(private val device: UiDevice) { val codeInputField = UiWaitUtils.waitElement(UiSelectorParams(className = "android.widget.EditText")) codeInputField.click() codeInputField.text = code + UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) return this } fun assertEnterYourUserNameInfoText(): RegistrationPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Resend code"), timeoutMillis = 10_000) - val info = UiWaitUtils.waitElement(userNameInfoText) + val info = UiWaitUtils.waitElement(userNameInfoText, timeoutMillis = 15_000) assertTrue("Username info not visible", !info.visibleBounds.isEmpty) return this } @@ -187,14 +226,53 @@ class RegistrationPage(private val device: UiDevice) { return this } - fun clickAllowNotificationButton(): RegistrationPage { - UiWaitUtils.waitElement(allowNotificationButton).click() + fun clickAllowNotificationButton(timeoutMs: Long = 15_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val button = allowNotificationButtons + .asSequence() + .mapNotNull(UiWaitUtils::findElementOrNull) + .firstOrNull { !it.visibleBounds.isEmpty && it.isEnabled } + + if (button != null) { + button.click() + return this + } + + SystemClock.sleep(200) + } + + // On some devices/runs the permission is already granted and this dialog never appears. return this } - fun clickDeclineShareDataAlert(): RegistrationPage { - UiWaitUtils.waitElement(declineButton).click() - return this + @Suppress("MagicNumber") + fun clickDeclineShareDataAlert(timeoutMs: Long = 10_000): RegistrationPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (SystemClock.uptimeMillis() < deadline) { + val decline = UiWaitUtils.findElementOrNull(declineButton) + if (decline != null && !decline.visibleBounds.isEmpty && decline.isEnabled) { + val bounds = decline.visibleBounds + runCatching { decline.click() } + val stillVisibleAfterClick = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (stillVisibleAfterClick && !bounds.isEmpty) { + device.click(bounds.centerX(), bounds.centerY()) + } + device.waitForIdle(300) + } + + val dialogVisible = UiWaitUtils.findElementOrNull(consentDialogTitle)?.let { !it.visibleBounds.isEmpty } == true + val declineVisible = UiWaitUtils.findElementOrNull(declineButton)?.let { !it.visibleBounds.isEmpty } == true + if (!dialogVisible && !declineVisible) { + return this + } + + SystemClock.sleep(150) + } + + throw AssertionError("Share data consent alert was not dismissed within ${timeoutMs}ms.") } fun clickAgreeShareDataAlert(): RegistrationPage { @@ -224,7 +302,7 @@ class RegistrationPage(private val device: UiDevice) { fun waitUntilRegistrationFlowIsCompleted(): RegistrationPage { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 14_000) + waitUntilElementGone(device, UiSelector().text("Confirm"), timeoutMillis = 16_000) return this } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt index 1f8e6799495..fe928478f43 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SettingsPage.kt @@ -19,6 +19,7 @@ package com.wire.android.tests.core.pages import android.content.Intent import android.net.Uri +import android.os.SystemClock import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -426,15 +427,19 @@ data class SettingsPage(private val device: UiDevice) { return this } - fun assertDeleteAccountConfirmationModalIsNoLongerVisible(): SettingsPage { - val modal = runCatching { - UiWaitUtils.waitElement(deleteAccountConfirmationModal) - }.getOrNull() + fun assertDeleteAccountConfirmationModalIsNoLongerVisible(timeoutMs: Long = 10_000): SettingsPage { + val deadline = SystemClock.uptimeMillis() + timeoutMs - if (modal != null && !modal.visibleBounds.isEmpty) { - throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") + while (SystemClock.uptimeMillis() < deadline) { + val modal = UiWaitUtils.findElementOrNull(deleteAccountConfirmationModal) + val isVisible = modal != null && !modal.visibleBounds.isEmpty + if (!isVisible) { + return this + } + SystemClock.sleep(150) } - return this + + throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") } fun selectBackupFileInDocumentsUI(teamHelper: TeamHelper, userAlias: String): SettingsPage { diff --git a/tests/testsSupport/src/main/service/TestService.kt b/tests/testsSupport/src/main/service/TestService.kt index 49a5a966d64..42d0fa484eb 100644 --- a/tests/testsSupport/src/main/service/TestService.kt +++ b/tests/testsSupport/src/main/service/TestService.kt @@ -249,7 +249,9 @@ class TestService(private val baseUri: String, private val testName: String) { put("expectsReadConfirmation", true) } put("text", params.text) - put("buttons", params.buttons) + if (params.buttons.length() > 0) { + put("buttons", params.buttons) + } put("legalHoldStatus", params.legalHoldStatus) } val result = sendHttpRequest(connection, requestBody) @@ -699,7 +701,9 @@ class TestService(private val baseUri: String, private val testName: String) { } private fun JSONObject.addButtonsIfPresent(buttons: JSONArray?) { - buttons?.let { put("buttons", it) } + if (buttons != null && buttons.length() > 0) { + put("buttons", buttons) + } } private fun JSONObject.addMessageTimerIfNeeded(messageTimer: Duration) { diff --git a/tests/testsSupport/src/main/service/TestServiceHelper.kt b/tests/testsSupport/src/main/service/TestServiceHelper.kt index ac62801f555..2a04ca12818 100644 --- a/tests/testsSupport/src/main/service/TestServiceHelper.kt +++ b/tests/testsSupport/src/main/service/TestServiceHelper.kt @@ -65,12 +65,22 @@ class TestServiceHelper( } } + private fun backendFor(user: ClientUser): BackendClient { + val backendName = user.backendName + return if (backendName.isNullOrBlank()) { + BackendClient.getDefault() + ?: throw IllegalStateException("No default backend configured for user '${user.name}'.") + } else { + BackendClient.loadBackend(backendName) + } + } + fun getSelfDeletingMessageTimeout(userAlias: String, conversationName: String): Duration { val user = usersManager.findUserByNameOrNameAlias(userAlias) // Only team users support enforced self-deleting messages user.teamId?.let { - val settings = BackendClient.loadBackend(user.backendName.orEmpty()).getSelfDeletingMessagesSettings(user) + val settings = backendFor(user).getSelfDeletingMessagesSettings(user) if (settings.getString("status") == "enabled") { val timeoutInSeconds = settings @@ -87,20 +97,39 @@ class TestServiceHelper( } } - // Personal user or team user without set enforced self-deleting message setting - + // Personal user or team user without enforced setting val resolvedConversationName = usersManager.replaceAliasesOccurrences( conversationName, ClientUserManager.FindBy.NAME_ALIAS ) - val messageTimerMillis = toConvoObjPersonal(user, resolvedConversationName).messageTimerInMilliseconds - if (messageTimerMillis > 0) { - return Duration.ofMillis(messageTimerMillis.toLong()) + val conversationMessageTimerMillis = getConversationMessageTimer(user, resolvedConversationName) + if (conversationMessageTimerMillis > 0) { + return Duration.ofMillis(conversationMessageTimerMillis.toLong()) } - // Otherwise check for local/client-side self-deleting message timeout - return Duration.ofSeconds(Long.MAX_VALUE) + return Duration.ofMillis(Int.MAX_VALUE.toLong()) // ~24.8 days, safe int millis + } + + private fun getConversationMessageTimer(user: ClientUser, conversationName: String): Int { + val isPersonalConversationName = runCatching { + // If this succeeds, conversationName is a user name/alias (1:1 style like "user4Name") + usersManager.findUserByNameOrNameAlias(conversationName) + }.isSuccess + + val conversation = if (isPersonalConversationName) { + // Personal first, fallback to group just in case + runCatching { toConvoObjPersonal(user, conversationName) } + .recoverCatching { toConvoObj(user, conversationName) } + .getOrThrow() + } else { + // Group first, fallback to personal just in case + runCatching { toConvoObj(user, conversationName) } + .recoverCatching { toConvoObjPersonal(user, conversationName) } + .getOrThrow() + } + + return conversation.messageTimerInMilliseconds } fun contactSendsLocalAudioPersonalMLSConversation( @@ -133,6 +162,34 @@ class TestServiceHelper( ) } + fun contactSendsLocalAudioConversation( + context: Context, + fileName: String, + senderAlias: String, + deviceName: String, + dstConvoName: String + ) { + val audio = getRawResourceAsFile(context, R.raw.test, fileName) + val conversation = toConvoObj(toClientUser(senderAlias), dstConvoName) + + if (audio?.exists() != true) { + throw Exception("Audio file not found") + } + + val convoId = conversation.qualifiedID.id + val convoDomain = conversation.qualifiedID.domain + + testServiceClient.sendFile( + toClientUser(senderAlias), + deviceName, + convoId, + convoDomain, + getSelfDeletingMessageTimeout(senderAlias, dstConvoName), + audio.absolutePath.orEmpty(), + "audio/mp4" + ) + } + fun userXAddedContactsToGroupChat( userAsNameAlias: String, contactsToAddNameAliases: String, @@ -144,7 +201,7 @@ class TestServiceHelper( .splitAliases(contactsToAddNameAliases) .map { toClientUser(it) } - BackendClient.loadBackend(userAs.backendName.orEmpty()).addUsersToGroupConversation( + backendFor(userAs).addUsersToGroupConversation( asUser = userAs, contacts = contactsToAdd, conversation = toConvoObj(userAs, chatName) @@ -246,21 +303,22 @@ class TestServiceHelper( } fun toConvoObjPersonal(owner: ClientUser, convoName: String): Conversation { - val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) - return backend.getPersonalConversationByName(owner, convoName) + val seekName = usersManager.findUserByNameOrNameAlias(convoName).name + ?: throw NoSuchElementException("User '$convoName' does not have a resolvable display name.") + val backend = backendFor(owner) + return backend.getPersonalConversationByName(owner, seekName) } fun toConvoObj(owner: ClientUser, convoName: String): Conversation { val convoName = usersManager.replaceAliasesOccurrences(convoName, ClientUserManager.FindBy.NAME_ALIAS) - val backend = BackendClient.loadBackend(owner.backendName.orEmpty()) + val backend = backendFor(owner) return backend.getConversationByName(owner, convoName) } suspend fun usersSetUniqueUsername(userNameAliases: String) { usersManager.splitAliases(userNameAliases).forEach { userNameAlias -> val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) backend.updateUniqueUsername( user, user.uniqueUsername.orEmpty() @@ -270,7 +328,7 @@ class TestServiceHelper( fun connectionRequestIsSentTo(userFromNameAlias: String, usersToNameAliases: String) { val userFrom = toClientUser(userFromNameAlias) - val backend = BackendClient.loadBackend(userFrom.backendName.orEmpty()) + val backend = backendFor(userFrom) val usersTo = usersManager .splitAliases(usersToNameAliases) .map(this::toClientUser) @@ -286,11 +344,11 @@ class TestServiceHelper( verificationCode: String? = null, deviceName: String? = null, ) { - val developmentApiEnabled = - BackendClient.loadBackend(toClientUser(ownerAlias).backendName.orEmpty()).isDevelopmentApiEnabled(toClientUser(ownerAlias)) + val owner = toClientUser(ownerAlias) + val developmentApiEnabled = backendFor(owner).isDevelopmentApiEnabled(owner) try { testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -299,7 +357,7 @@ class TestServiceHelper( try { TimeUnit.SECONDS.sleep(300) testServiceClient.login( - toClientUser(ownerAlias), + owner, verificationCode, deviceName, developmentApiEnabled @@ -324,21 +382,17 @@ class TestServiceHelper( .map(this::toClientUser) } - val backend = if (chatOwner.backendName.isNullOrEmpty()) { - BackendClient.getDefault() - } else { - BackendClient.loadBackend(chatOwner.backendName.orEmpty()) - } + val backend = backendFor(chatOwner) runBlocking { - val dstTeam = backend?.getTeamByName(chatOwner, teamName) - backend?.createTeamConversation(chatOwner, participants, chatName, dstTeam!!) + val dstTeam = backend.getTeamByName(chatOwner, teamName) + backend.createTeamConversation(chatOwner, participants, chatName, dstTeam) } } fun isSendReadReceiptEnabled(userNameAlias: String): Boolean { val user = toClientUser(userNameAlias) - val backend = BackendClient.loadBackend(user.backendName.orEmpty()) + val backend = backendFor(user) val json = runBlocking { backend.getPropertyValues(user) } @@ -375,7 +429,7 @@ class TestServiceHelper( fun syncUserIdsForUsersCreatedThroughIdP(ownerNameAlias: String, user: ClientUser) { user.getUserIdThroughOwner = Callable { val asUser = toClientUser(ownerNameAlias) - val backend = BackendClient.loadBackend(asUser.backendName.orEmpty()) + val backend = backendFor(asUser) val teamMembers = backend.getTeamMembers(asUser) for (member in teamMembers) { @@ -407,10 +461,16 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObj(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) } - fun userSendMessageToConversationObj( + fun userSendMessageToPersonalMlsConversation( senderAlias: String, msg: String, deviceName: String, @@ -419,7 +479,27 @@ class TestServiceHelper( ) { val clientUser = toClientUser(senderAlias) val conversation = toConvoObjPersonal(clientUser, dstConvoName) - sendMessageInternal(clientUser, conversation, msg, deviceName, isSelfDeleting) + sendMessageInternal( + clientUser = clientUser, + conversation = conversation, + msg = msg, + deviceName = deviceName, + timeout = resolveMessageTimeout(senderAlias, dstConvoName, isSelfDeleting) + ) + } + + private fun resolveMessageTimeout( + senderAlias: String, + dstConvoName: String, + isSelfDeleting: Boolean + ): Duration { + return if (isSelfDeleting) { + Duration.ofSeconds(1000) + } else { + getSelfDeletingMessageTimeout(senderAlias, dstConvoName).let { timeout -> + if (timeout == Duration.ofMillis(Int.MAX_VALUE.toLong())) Duration.ZERO else timeout + } + } } fun userXSharesLocationTo( @@ -450,7 +530,7 @@ class TestServiceHelper( conversation: Conversation, msg: String, deviceName: String, - isSelfDeleting: Boolean + timeout: Duration ) { val convoId = conversation.qualifiedID.id val convoDomain = conversation.qualifiedID.domain @@ -461,17 +541,24 @@ class TestServiceHelper( else -> false } - testServiceClient.sendText( - SendTextParams( - owner = clientUser, - deviceName = deviceName, - convoDomain = convoDomain, - convoId = convoId, - timeout = if (isSelfDeleting) Duration.ofSeconds(1000) else Duration.ZERO, - expectsReadConfirmation = expReadConfirm, - text = msg, - legalHoldStatus = LegalHoldStatus.DISABLED.code, + try { + testServiceClient.sendText( + SendTextParams( + owner = clientUser, + deviceName = deviceName, + convoDomain = convoDomain, + convoId = convoId, + timeout = timeout, + expectsReadConfirmation = expReadConfirm, + text = msg, + legalHoldStatus = LegalHoldStatus.DISABLED.code, + ) ) - ) + } catch (e: Throwable) { + throw AssertionError( + "Failed to send message '$msg' to conversationId='$convoId' for user='${clientUser.name}' on device '$deviceName'.", + e + ) + } } } diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index f251e3f2a67..4925f45bad2 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -19,6 +19,7 @@ package uiautomatorutils import android.graphics.Rect import android.os.SystemClock +import android.view.accessibility.AccessibilityEvent import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.BySelector @@ -29,6 +30,8 @@ import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import java.io.IOException import java.util.regex.Pattern +import junit.framework.TestCase.assertTrue + private const val TIMEOUT_IN_MILLISECONDS = 10000L data class UiSelectorParams( @@ -100,7 +103,7 @@ object UiWaitUtils { device.waitForIdle(500) // 2) Stabilize: refetch until bounds are stable & usable - val end = SystemClock.uptimeMillis() + 1_500 + val end = SystemClock.uptimeMillis() + 3_000 var lastBounds: Rect? = null while (SystemClock.uptimeMillis() < end) { @@ -175,4 +178,74 @@ object UiWaitUtils { } } } + + fun waitUntilVisible( + params: UiSelectorParams, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + errorMessage: String + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + try { + val sel = params.toBySelector() + if (!device.wait(Until.hasObject(sel), timeoutMs)) { + throw AssertionError() + } + } catch (e: AssertionError) { + throw AssertionError(errorMessage, e) + } + } + + fun waitUntilToastIsDisplayed( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "Toast message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + fun iSeeSystemMessage( + message: String, + timeoutMs: Long = 5_000 + ) { + waitUntilVisible( + params = UiSelectorParams(textContains = message), + timeoutMs = timeoutMs, + errorMessage = "System message containing '$message' was not displayed within ${timeoutMs}ms." + ) + } + + @Suppress("MagicNumber") + fun assertToastDisplayed(text: String, trigger: () -> Unit, timeoutMs: Long = 5_000L) { + var toastDisplayed = false + val startTimeMs = System.currentTimeMillis() + + val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation + + uiAutomation.setOnAccessibilityEventListener { event -> + if (event.eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) { + val className = event.className?.toString().orEmpty() + val eventText = event.text?.joinToString(" ").orEmpty() + + if (className.contains("android.widget.Toast") && eventText.contains(text, ignoreCase = true)) { + toastDisplayed = true + } + } + } + + try { + // IMPORTANT: trigger AFTER listener is set + trigger() + + while (!toastDisplayed && System.currentTimeMillis() - startTimeMs < timeoutMs) { + Thread.sleep(50) + } + + assertTrue("Toast with text '$text' not found within ${timeoutMs}ms", toastDisplayed) + } finally { + uiAutomation.setOnAccessibilityEventListener(null) + } + } } From 2ab4a2fead4a4e16e64d05b49fc7684aaeccdd5b Mon Sep 17 00:00:00 2001 From: emmaoke-w Date: Fri, 10 Apr 2026 12:58:44 +0200 Subject: [PATCH 2/2] clarify settings menu retry flow --- .../tests/core/pages/ConversationListPage.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt index d872f913672..b03d297e39d 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConversationListPage.kt @@ -65,6 +65,10 @@ data class ConversationListPage(private val device: UiDevice) { return this } + /** + * The navigation drawer can appear before the Settings entry is fully attached and clickable. + * Retry for a short window, reopening the drawer at a throttled pace until the Settings row is stable. + */ fun clickSettingsButtonOnMenuEntry(timeoutMs: Long = 10_000): ConversationListPage { val deadline = SystemClock.uptimeMillis() + timeoutMs var lastMenuClickAt = 0L @@ -74,11 +78,7 @@ data class ConversationListPage(private val device: UiDevice) { return this } - val now = SystemClock.uptimeMillis() - if (now - lastMenuClickAt >= 600 && tryClickIfVisible(mainMenuButton)) { - lastMenuClickAt = now - device.waitForIdle(300) - } + lastMenuClickAt = reopenMenuIfNeeded(lastMenuClickAt) SystemClock.sleep(120) } @@ -86,6 +86,16 @@ data class ConversationListPage(private val device: UiDevice) { throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") } + private fun reopenMenuIfNeeded(lastMenuClickAt: Long, minIntervalMs: Long = 600L): Long { + val now = SystemClock.uptimeMillis() + if (now - lastMenuClickAt < minIntervalMs || !tryClickIfVisible(mainMenuButton)) { + return lastMenuClickAt + } + + device.waitForIdle(300) + return now + } + private fun tryClickIfVisible(selector: UiSelectorParams): Boolean { val element = UiWaitUtils.findElementOrNull(selector) ?: return false return try {