From a86a6f54e8746f337897bb24124750e328920a50 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Thu, 23 Apr 2026 12:24:37 +0200 Subject: [PATCH 1/6] chore: clean up api and extend testcore helpers --- .../tests/core/pages/ConversationListPage.kt | 79 +++++---- .../tests/core/pages/ConversationViewPage.kt | 87 ++++------ .../tests/core/pages/NotificationsPage.kt | 22 ++- .../tests/core/pages/RegistrationPage.kt | 81 ++++----- .../wire/android/tests/core/pages/SSOPage.kt | 20 +-- .../android/tests/core/pages/SettingsPage.kt | 59 +++---- .../src/main/uiautomatorutils/UiWaitUtils.kt | 161 +++++++++++++++--- 7 files changed, 285 insertions(+), 224 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 b03d297e39d..cbd02313374 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 @@ -19,7 +19,6 @@ 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 @@ -51,6 +50,7 @@ data class ConversationListPage(private val device: UiDevice) { private val userConversationNamePendingLabelSelector = UiSelector().description("pending approval of connection request") + private val pendingApprovalLabel = UiSelectorParams(description = "pending approval of connection request") fun assertConversationListVisible(): ConversationListPage { val heading = UiWaitUtils.waitElement(conversationListHeading) Assert.assertTrue( @@ -70,25 +70,29 @@ data class ConversationListPage(private val device: UiDevice) { * 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 - while (SystemClock.uptimeMillis() < deadline) { - if (tryClickIfVisible(settingsButton)) { - return this + val success = UiWaitUtils.retryUntilTimeout(timeoutMs = timeoutMs, pollingIntervalMs = 120) { + if (UiWaitUtils.clickWhenClickable(settingsButton, timeoutMs = 200, pollingIntervalMs = 100)) { + true + } else { + lastMenuClickAt = reopenMenuIfNeeded(lastMenuClickAt) + false } - - lastMenuClickAt = reopenMenuIfNeeded(lastMenuClickAt) - - SystemClock.sleep(120) } - throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") + if (!success) { + throw AssertionError("Settings menu entry was not found within ${timeoutMs}ms.") + } + return this } private fun reopenMenuIfNeeded(lastMenuClickAt: Long, minIntervalMs: Long = 600L): Long { val now = SystemClock.uptimeMillis() - if (now - lastMenuClickAt < minIntervalMs || !tryClickIfVisible(mainMenuButton)) { + if ( + now - lastMenuClickAt < minIntervalMs || + !UiWaitUtils.clickWhenClickable(mainMenuButton, timeoutMs = 200, pollingIntervalMs = 100) + ) { return lastMenuClickAt } @@ -152,14 +156,13 @@ data class ConversationListPage(private val device: UiDevice) { } fun clickGroupConversation(conversationName: String): ConversationListPage { - val conversation = device.wait( - androidx.test.uiautomator.Until.findObject(By.text(conversationName)), - 10_000 + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(text = conversationName), + timeoutMs = 10_000, + errorMessage = "Group conversation '$conversationName' was not found." ) - if (conversation == null) { - throw AssertionError("Group conversation '$conversationName' was not found.") - } + val conversation = UiWaitUtils.waitElement(UiSelectorParams(text = conversationName)) conversation.click() return this } @@ -189,18 +192,16 @@ data class ConversationListPage(private val device: UiDevice) { } 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") + val closeButton = UiSelectorParams( + className = "android.view.View", + description = "Close new conversation view" ) - - if (!close.waitForExists(timeoutMs)) { - throw AssertionError("Close button not found within ${timeoutMs}ms") - } - + UiWaitUtils.waitUntilVisibleOrThrow( + params = closeButton, + timeoutMs = timeoutMs, + errorMessage = "Close button not found within ${timeoutMs}ms" + ) + val close = UiWaitUtils.waitElement(closeButton) close.click() return this @@ -214,24 +215,22 @@ data class ConversationListPage(private val device: UiDevice) { @Suppress("ThrowsCount") fun assertConversationNameWithPendingStatusVisibleInConversationList(userName: String): ConversationListPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - - // 1) Assert user name is visible try { - val userObj = device.findObject(UiSelector().text(userName)) - if (!userObj.waitForExists(10_000)) { - throw AssertionError("User '$userName' is not visible in the conversation list") - } + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(text = userName), + timeoutMs = 10_000, + errorMessage = "User '$userName' is not visible in the conversation list" + ) } catch (e: Throwable) { throw AssertionError("User '$userName' is not visible in the conversation list", e) } - // 2) Assert the 'pending' badge is visible try { - val pendingObj = device.findObject(userConversationNamePendingLabelSelector) - if (!pendingObj.waitForExists(10_000)) { - throw AssertionError("Pending status is not visible for user '$userName'") - } + UiWaitUtils.waitUntilVisibleOrThrow( + params = pendingApprovalLabel, + timeoutMs = 10_000, + errorMessage = "Pending status is not visible for user '$userName'" + ) } catch (e: Throwable) { throw AssertionError("Pending status is not visible for user '$userName'", e) } 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 c04a603a7a3..9ef2cb8bf6e 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 @@ -23,7 +23,6 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector -import androidx.test.uiautomator.Until import junit.framework.TestCase.assertFalse import org.junit.Assert import uiautomatorutils.UiSelectorParams @@ -123,9 +122,11 @@ data class ConversationViewPage(private val device: UiDevice) { } fun assertAudioTimeIsNotZeroAnymore(): ConversationViewPage { - val gone = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - .wait(Until.gone(By.text("00:00")), 5000) - assertTrue("Audio time is still at 00:00, expected it to have changed", gone) + UiWaitUtils.waitUntilGoneOrThrow( + selector = By.text("00:00"), + timeoutMs = 5_000, + errorMessage = "Audio time is still at 00:00, expected it to have changed" + ) return this } @@ -186,22 +187,15 @@ data class ConversationViewPage(private val device: UiDevice) { fun assertFileActionModalIsVisible(timeoutMs: Long = 8_000): ConversationViewPage { val modalAnchors = listOf(modalTextLocator, saveButton, openButton, cancelButton) - val deadline = SystemClock.uptimeMillis() + timeoutMs - - while (SystemClock.uptimeMillis() < deadline) { - val isVisible = modalAnchors - .asSequence() - .mapNotNull(UiWaitUtils::findElementOrNull) - .any { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } - - if (isVisible) { - return this - } - - SystemClock.sleep(150) + val visibleAnchor = UiWaitUtils.waitAnyVisible( + selectors = modalAnchors, + timeoutMs = timeoutMs, + pollingIntervalMs = 150 + ) + if (visibleAnchor == null) { + throw AssertionError("The file action modal was not visible within ${timeoutMs}ms.") } - - throw AssertionError("The file action modal was not visible within ${timeoutMs}ms.") + return this } fun assertImageFileWithNameIsVisible(fileName: String): ConversationViewPage { @@ -267,10 +261,10 @@ data class ConversationViewPage(private val device: UiDevice) { } fun waitForPreviousFileSavedToastToDisappear(timeoutMillis: Long = 7_000): ConversationViewPage { - val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - uiDevice.wait( - Until.gone(By.textContains(fileSavedToastMessage)), - timeoutMillis + UiWaitUtils.waitUntilGoneOrThrow( + selector = By.textContains(fileSavedToastMessage), + timeoutMs = timeoutMillis, + errorMessage = "File saved toast did not disappear within ${timeoutMillis}ms." ) return this } @@ -379,20 +373,16 @@ data class ConversationViewPage(private val device: UiDevice) { } fun assertMessageNotVisible(text: String, timeoutSeconds: Int = 5) { - try { - val deadline = System.currentTimeMillis() + timeoutSeconds * 1000L - - while (System.currentTimeMillis() < deadline) { - val element = findElementOrNull(UiSelectorParams(text = text)) - if (element != null) { - throw AssertionError("Message '$text' is still present in the conversation.") - } - Thread.sleep(250) - } - } catch (e: AssertionError) { + val notVisible = UiWaitUtils.retryUntilTimeout( + timeoutMs = timeoutSeconds * 1_000L, + pollingIntervalMs = 250 + ) { + findElementOrNull(UiSelectorParams(text = text)) == null + } + if (!notVisible) { throw AssertionError( "Expected message '$text' to be absent, but it was found within ${timeoutSeconds}s.", - e + AssertionError("Message '$text' is still present in the conversation.") ) } } @@ -551,25 +541,18 @@ data class ConversationViewPage(private val device: UiDevice) { 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 + val mlsMarker = UiWaitUtils.waitAnyVisible( + selectors = mlsUpgradeMessageSelectors, + timeoutMs = timeoutMs, + pollingIntervalMs = 200 + ) + 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) } - - SystemClock.sleep(200) + return this } - 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/NotificationsPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/NotificationsPage.kt index f333ed54acc..ce72c590c48 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/NotificationsPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/NotificationsPage.kt @@ -17,24 +17,22 @@ */ 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 -import androidx.test.uiautomator.Until +import uiautomatorutils.UiWaitUtils class NotificationsPage(private val device: UiDevice) { fun waitUntilNotificationPopUpGone(timeoutMillis: Long = 10_000L) { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) val replyButton = By.text("Reply") - // Wait 1 second before checking for the notification pop-up - SystemClock.sleep(1000L) - // If the Reply button is found, wait until it's gone - if (device.hasObject(replyButton)) { - val disappeared = device.wait(Until.gone(replyButton), timeoutMillis) - if (!disappeared) { - throw AssertionError("Notification pop-up with 'Reply' did not disappear within $timeoutMillis ms") - } + val appearedWithinProbeWindow = UiWaitUtils.retryUntilTimeout(timeoutMs = 1_000, pollingIntervalMs = 100) { + device.hasObject(replyButton) + } + if (appearedWithinProbeWindow) { + UiWaitUtils.waitUntilGoneOrThrow( + selector = replyButton, + timeoutMs = timeoutMillis, + errorMessage = "Notification pop-up with 'Reply' did not disappear within $timeoutMillis 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 26b7fe58a31..94bfe396adc 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,7 +18,6 @@ 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 @@ -72,52 +71,52 @@ class RegistrationPage(private val device: UiDevice) { } fun enterPersonalUserRegistrationEmail(email: String): RegistrationPage { - repeat(3) { - try { + val success = UiWaitUtils.retryUntilTimeout(timeoutMs = 6_000, pollingIntervalMs = 150) { + runCatching { 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) - } + }.isSuccess } - throw AssertionError("Could not enter registration email: email input field was unstable.") + if (!success) { + throw AssertionError("Could not enter registration email: email input field was unstable.") + } + return this } @Suppress("NestedBlockDepth") fun clickLoginButton(timeoutMs: Long = 10_000): RegistrationPage { - val deadline = SystemClock.uptimeMillis() + timeoutMs var lastError: AssertionError? = null - while (SystemClock.uptimeMillis() < deadline) { + val success = UiWaitUtils.retryUntilTimeout(timeoutMs = timeoutMs, pollingIntervalMs = 200) { try { UiWaitUtils.waitElement(loginButton, timeoutMillis = 1_500).click() - return this + true } catch (e: AssertionError) { lastError = e try { val button = UiWaitUtils.findElementOrNull(loginButton) if (button != null && !button.visibleBounds.isEmpty && button.isEnabled) { button.click() - return this + true + } else { + false } } catch (_: StaleObjectException) { - // Retry with a freshly resolved node. + false } } catch (_: StaleObjectException) { - // Retry with a freshly resolved node. + false } - - SystemClock.sleep(200) } - throw AssertionError( - "Login button was not clickable within ${timeoutMs}ms.", - lastError - ) + if (!success) { + throw AssertionError( + "Login button was not clickable within ${timeoutMs}ms.", + lastError + ) + } + return this } fun clickCreateAccountButton(): RegistrationPage { @@ -227,20 +226,18 @@ class RegistrationPage(private val device: UiDevice) { } 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) { + UiWaitUtils.retryUntilTimeout(timeoutMs = timeoutMs, pollingIntervalMs = 200) { + val button = UiWaitUtils.waitAnyVisible( + selectors = allowNotificationButtons, + timeoutMs = 200, + pollingIntervalMs = 100 + ) + if (button != null && button.isEnabled) { button.click() - return this + true + } else { + false } - - SystemClock.sleep(200) } // On some devices/runs the permission is already granted and this dialog never appears. @@ -249,9 +246,7 @@ class RegistrationPage(private val device: UiDevice) { @Suppress("MagicNumber") fun clickDeclineShareDataAlert(timeoutMs: Long = 10_000): RegistrationPage { - val deadline = SystemClock.uptimeMillis() + timeoutMs - - while (SystemClock.uptimeMillis() < deadline) { + val dismissed = UiWaitUtils.retryUntilTimeout(timeoutMs = timeoutMs, pollingIntervalMs = 150) { val decline = UiWaitUtils.findElementOrNull(declineButton) if (decline != null && !decline.visibleBounds.isEmpty && decline.isEnabled) { val bounds = decline.visibleBounds @@ -265,14 +260,12 @@ class RegistrationPage(private val device: UiDevice) { 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) + !dialogVisible && !declineVisible } - - throw AssertionError("Share data consent alert was not dismissed within ${timeoutMs}ms.") + if (!dismissed) { + throw AssertionError("Share data consent alert was not dismissed within ${timeoutMs}ms.") + } + return this } fun clickAgreeShareDataAlert(): RegistrationPage { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt index 357a1d23703..a5810434771 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/SSOPage.kt @@ -17,12 +17,9 @@ */ package com.wire.android.tests.core.pages -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils -import uiautomatorutils.UiWaitUtils.toBySelector data class SSOPage(private val device: UiDevice) { @@ -48,18 +45,11 @@ data class SSOPage(private val device: UiDevice) { } fun waitUntilOktaPageLoaded(timeoutMs: Long = 20_000): SSOPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - try { - val sel = oktaSignInButton.toBySelector() - if (!device.wait(Until.hasObject(sel), timeoutMs)) { - throw AssertionError() - } - } catch (e: AssertionError) { - throw AssertionError( - "Okta page did not load: Email and password input field is not visible", - e - ) - } + UiWaitUtils.waitUntilVisibleOrThrow( + params = oktaSignInButton, + timeoutMs = timeoutMs, + errorMessage = "Okta page did not load: Email and password input field is not visible" + ) 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 fe928478f43..b205a0b51b0 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,14 +19,12 @@ 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 import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector -import androidx.test.uiautomator.Until import backendUtils.team.TeamHelper import junit.framework.TestCase.assertFalse import org.hamcrest.CoreMatchers.`is` @@ -174,12 +172,11 @@ data class SettingsPage(private val device: UiDevice) { } fun iSeeBackupConfirmation(text: String): SettingsPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val backupConfirmed = device.wait( - Until.findObject(By.textContains(text)), - 5_000 + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(textContains = text), + timeoutMs = 5_000, + errorMessage = "Expected message '$text' was not displayed" ) - assertTrue("Expected message '$text' was not displayed", backupConfirmed != null) return this } @@ -393,14 +390,11 @@ data class SettingsPage(private val device: UiDevice) { } fun assertEmailVerifiedMessageVisibleOnChrome(timeoutMillis: Long = 15_000): SettingsPage { - val emailVerifiedText = device.wait( - Until.findObject(By.textContains("Email verified")), - timeoutMillis + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(textContains = "Email verified"), + timeoutMs = timeoutMillis, + errorMessage = "Email Verified text not found in Chrome after 15 seconds." ) - - if (emailVerifiedText == null) { - throw AssertionError("Email Verified text not found in Chrome after 15 seconds.") - } return this } @@ -417,29 +411,23 @@ data class SettingsPage(private val device: UiDevice) { } fun assertChromeUrlIsDisplayed(expectedUrl: String): SettingsPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - // Wait and find the URL element in the address bar by partial text - val urlElement = device.wait( - Until.findObject(By.textContains(expectedUrl)), - 5_000 + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(textContains = expectedUrl), + timeoutMs = 5_000, + errorMessage = "Expected URL '$expectedUrl' was not found in Chrome" ) - assertTrue("Expected URL '$expectedUrl' was not found in Chrome", urlElement != null) return this } fun assertDeleteAccountConfirmationModalIsNoLongerVisible(timeoutMs: Long = 10_000): SettingsPage { - val deadline = SystemClock.uptimeMillis() + timeoutMs - - while (SystemClock.uptimeMillis() < deadline) { + val isGone = UiWaitUtils.retryUntilTimeout(timeoutMs = timeoutMs, pollingIntervalMs = 150) { val modal = UiWaitUtils.findElementOrNull(deleteAccountConfirmationModal) - val isVisible = modal != null && !modal.visibleBounds.isEmpty - if (!isVisible) { - return this - } - SystemClock.sleep(150) + modal == null || modal.visibleBounds.isEmpty } - - throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") + if (!isGone) { + throw AssertionError("Delete account confirmation modal is still visible (expected it to be gone)") + } + return this } fun selectBackupFileInDocumentsUI(teamHelper: TeamHelper, userAlias: String): SettingsPage { @@ -460,12 +448,11 @@ data class SettingsPage(private val device: UiDevice) { } fun waitUntilThisTextIsDisplayedOnBackupAlert(text: String): SettingsPage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val text = device.findObject(UiSelector().text(text)) - - if (!text.waitForExists(5_000)) { - throw AssertionError("Text '$text' was not displayed on the backup alert within timeout") - } + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(text = text), + timeoutMs = 5_000, + errorMessage = "Text '$text' was not displayed on the backup alert within timeout" + ) return this } diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index 4925f45bad2..e7ebdb7d37f 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -33,6 +33,9 @@ import java.util.regex.Pattern import junit.framework.TestCase.assertTrue private const val TIMEOUT_IN_MILLISECONDS = 10000L +private const val DEFAULT_POLLING_INTERVAL_MS = 200L +private const val STABILIZE_TIMEOUT_MS = 3_000L +private const val STABILIZE_POLLING_INTERVAL_MS = 100L data class UiSelectorParams( val text: String? = null, @@ -52,6 +55,7 @@ data class UiSelectorParams( * ✔️ Works for both interactive (buttons) and passive (labels) elements without extra parameters */ +@Suppress("TooManyFunctions") object UiWaitUtils { private fun buildSelector(params: UiSelectorParams): BySelector { @@ -87,6 +91,116 @@ object UiWaitUtils { } } + fun retryUntilTimeout( + timeoutMs: Long, + pollingIntervalMs: Long = DEFAULT_POLLING_INTERVAL_MS, + condition: () -> Boolean + ): Boolean { + val deadline = SystemClock.uptimeMillis() + timeoutMs + while (SystemClock.uptimeMillis() < deadline) { + if (condition()) { + return true + } + SystemClock.sleep(pollingIntervalMs) + } + return condition() + } + + fun waitUntilVisibleOrThrow( + params: UiSelectorParams, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + errorMessage: String + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val isVisible = retryUntilTimeout( + timeoutMs = timeoutMs, + pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS + ) { + runCatching { + device.wait(Until.hasObject(params.toBySelector()), DEFAULT_POLLING_INTERVAL_MS) + }.getOrDefault(false) + } + + if (!isVisible) { + throw AssertionError(errorMessage) + } + } + + fun waitAnyVisible( + selectors: List, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + pollingIntervalMs: Long = DEFAULT_POLLING_INTERVAL_MS + ): UiObject2? { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + var found: UiObject2? = null + + val isFound = retryUntilTimeout( + timeoutMs = timeoutMs, + pollingIntervalMs = pollingIntervalMs + ) { + found = selectors + .asSequence() + .mapNotNull(::findElementOrNull) + .firstOrNull { runCatching { !it.visibleBounds.isEmpty }.getOrDefault(false) } + found != null + } + + return if (isFound) found else null + } + + fun clickWhenClickable( + params: UiSelectorParams, + timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, + pollingIntervalMs: Long = DEFAULT_POLLING_INTERVAL_MS + ): Boolean { + return retryUntilTimeout( + timeoutMs = timeoutMs, + pollingIntervalMs = pollingIntervalMs + ) { + val element = findElementOrNull(params) ?: return@retryUntilTimeout false + try { + if (!element.visibleBounds.isEmpty && element.isEnabled) { + element.click() + true + } else { + false + } + } catch (_: StaleObjectException) { + false + } + } + } + + fun waitUntilGoneOrThrow( + selector: BySelector, + timeoutMs: Long = 30_000, + errorMessage: String + ) { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val isGone = device.wait(Until.gone(selector), timeoutMs) + if (!isGone) { + throw AssertionError(errorMessage) + } + } + + fun waitUntilGoneOrThrow( + device: UiDevice, + selector: UiSelector, + timeoutMillis: Long = 30_000, + pollingInterval: Long = 500, + errorMessage: String = "Element matching selector [$selector] did not disappear within timeout." + ) { + val isGone = retryUntilTimeout( + timeoutMs = timeoutMillis, + pollingIntervalMs = pollingInterval + ) { + !device.findObject(selector).exists() + } + if (!isGone) { + throw AssertionError(errorMessage) + } + } + @Suppress("MagicNumber", "NestedBlockDepth", "CyclomaticComplexMethod", "ComplexCondition") fun waitElement( params: UiSelectorParams, @@ -103,10 +217,13 @@ object UiWaitUtils { device.waitForIdle(500) // 2) Stabilize: refetch until bounds are stable & usable - val end = SystemClock.uptimeMillis() + 3_000 var lastBounds: Rect? = null + var stableElement: UiObject2? = null - while (SystemClock.uptimeMillis() < end) { + retryUntilTimeout( + timeoutMs = STABILIZE_TIMEOUT_MS, + pollingIntervalMs = STABILIZE_POLLING_INTERVAL_MS + ) { val obj = try { device.findObject(sel) } catch (_: StaleObjectException) { @@ -122,15 +239,19 @@ object UiWaitUtils { // Same bounds twice in a row → considered stable if (onScreen && nonZero && enabled && lastBounds != null && lastBounds == b) { - return obj + stableElement = obj + return@retryUntilTimeout true } lastBounds = b } catch (_: StaleObjectException) { // re-loop } } + false + } - SystemClock.sleep(100) + if (stableElement != null) { + return stableElement as UiObject2 } throw AssertionError("Element found but not stable/visible with selector: ${describe(params)}") @@ -151,18 +272,12 @@ object UiWaitUtils { timeoutMillis: Long = 30_000, pollingInterval: Long = 500 ) { - val deadline = SystemClock.uptimeMillis() + timeoutMillis - - while (SystemClock.uptimeMillis() < deadline) { - val element = device.findObject(selector) - if (!element.exists()) { - return - } - - SystemClock.sleep(pollingInterval) - } - - throw AssertionError("Element matching selector [$selector] did not disappear within timeout.") + waitUntilGoneOrThrow( + device = device, + selector = selector, + timeoutMillis = timeoutMillis, + pollingInterval = pollingInterval + ) } @Suppress("MagicNumber") @@ -184,15 +299,11 @@ object UiWaitUtils { 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) - } + waitUntilVisibleOrThrow( + params = params, + timeoutMs = timeoutMs, + errorMessage = errorMessage + ) } fun waitUntilToastIsDisplayed( From 0a35c63d242fb0deda620e66b0bc3ce0ea265b84 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Thu, 23 Apr 2026 14:15:23 +0200 Subject: [PATCH 2/6] chore: clean up testing core api and docs and adr --- ...automator-wait-helpers-for-test-modules.md | 41 ++++++++++ .../src/main/uiautomatorutils/UiWaitUtils.kt | 77 ++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0011-centralized-uiautomator-wait-helpers-for-test-modules.md diff --git a/docs/adr/0011-centralized-uiautomator-wait-helpers-for-test-modules.md b/docs/adr/0011-centralized-uiautomator-wait-helpers-for-test-modules.md new file mode 100644 index 00000000000..7cb26cef146 --- /dev/null +++ b/docs/adr/0011-centralized-uiautomator-wait-helpers-for-test-modules.md @@ -0,0 +1,41 @@ +# 11. Centralized UIAutomator Wait Helpers for Test Modules + +Date: 2026-04-23 + +## Status + +Accepted + +## Context + +The `:tests:testsCore` and `:tests:testsSupport` modules had duplicated synchronization logic: +- manual polling loops +- ad-hoc sleeps +- repeated "wait until visible/gone" patterns +- repeated stale-object retry logic + +This duplication made tests harder to maintain and increased the risk of inconsistent behavior and flaky waits across page objects and critical flows. + +## Decision + +We centralize reusable wait/select/retry behavior in `:tests:testsSupport` under `uiautomatorutils.UiWaitUtils`, and we migrate `:tests:testsCore` callers to use those helpers. + +The shared helper surface includes: +- `retryUntilTimeout(...)` +- `waitUntilVisibleOrThrow(...)` +- `waitUntilGoneOrThrow(...)` for `BySelector` and `UiSelector` +- `waitAnyVisible(...)` +- `clickWhenClickable(...)` + +Compatibility wrappers remain available (`waitUntilVisible`, `waitUntilElementGone`, `waitElement`) and are internally aligned with the same reusable primitives. + +## Consequences + +Positive: +- less duplicated synchronization logic in `:tests:testsCore` +- more consistent wait behavior and error handling across tests +- simpler future refactors (for example Kotlin Duration migration in follow-up work) + +Trade-offs: +- `UiWaitUtils` becomes the main synchronization entrypoint and must be kept well documented +- helper behavior changes can affect many tests at once, so updates require targeted regression checks diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index e7ebdb7d37f..8be2a684155 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -50,9 +50,10 @@ data class UiSelectorParams( ) /** - * ✔️ Waits until the element exists - * ✔️ Confirms it's visibly rendered on screen - * ✔️ Works for both interactive (buttons) and passive (labels) elements without extra parameters + * Utility methods for robust UIAutomator synchronization in instrumentation tests. + * + * This object centralizes visibility/gone waits, polling retries, and click retries so page objects + * in `:tests:testsCore` can avoid local wait/sleep loops and share consistent timeout semantics. */ @Suppress("TooManyFunctions") @@ -78,10 +79,18 @@ object UiWaitUtils { return requireNotNull(selector) { "At least one selector must be provided" } } + /** + * Converts [UiSelectorParams] into a [BySelector] used by UIAutomator `By.*` based queries. + */ fun UiSelectorParams.toBySelector(): BySelector { return UiWaitUtils.buildSelector(this) } + /** + * Finds an element once and returns `null` when it is not currently available. + * + * This is intentionally non-throwing and is useful in polling loops or optional element checks. + */ fun findElementOrNull(selector: UiSelectorParams): UiObject2? { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) return try { @@ -91,6 +100,11 @@ object UiWaitUtils { } } + /** + * Repeatedly evaluates [condition] until it returns `true` or [timeoutMs] expires. + * + * @return `true` if [condition] succeeded before timeout, otherwise `false`. + */ fun retryUntilTimeout( timeoutMs: Long, pollingIntervalMs: Long = DEFAULT_POLLING_INTERVAL_MS, @@ -106,6 +120,11 @@ object UiWaitUtils { return condition() } + /** + * Waits until an element matching [params] is visible, then returns. + * + * Throws [AssertionError] with [errorMessage] when the element does not appear in time. + */ fun waitUntilVisibleOrThrow( params: UiSelectorParams, timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, @@ -126,6 +145,11 @@ object UiWaitUtils { } } + /** + * Waits until any selector from [selectors] resolves to a visible element. + * + * @return the first visible [UiObject2], or `null` when no selector becomes visible in time. + */ fun waitAnyVisible( selectors: List, timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, @@ -148,6 +172,13 @@ object UiWaitUtils { return if (isFound) found else null } + /** + * Waits for an element to become visible and enabled, then clicks it. + * + * Handles transient `StaleObjectException` by retrying until timeout. + * + * @return `true` if the click succeeded within timeout, otherwise `false`. + */ fun clickWhenClickable( params: UiSelectorParams, timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, @@ -171,6 +202,11 @@ object UiWaitUtils { } } + /** + * Waits until an element matched by [selector] disappears from the UI hierarchy. + * + * Throws [AssertionError] with [errorMessage] when the element is still present after timeout. + */ fun waitUntilGoneOrThrow( selector: BySelector, timeoutMs: Long = 30_000, @@ -183,6 +219,11 @@ object UiWaitUtils { } } + /** + * Waits until an element matched by [UiSelector] disappears. + * + * This variant is intended for call sites still using classic `UiSelector`. + */ fun waitUntilGoneOrThrow( device: UiDevice, selector: UiSelector, @@ -201,6 +242,12 @@ object UiWaitUtils { } } + /** + * Waits for an element to appear and then stabilizes it before returning. + * + * Stabilization requires visible, on-screen, enabled bounds repeated across two probes, reducing + * flaky interactions caused by transient or stale nodes. + */ @Suppress("MagicNumber", "NestedBlockDepth", "CyclomaticComplexMethod", "ComplexCondition") fun waitElement( params: UiSelectorParams, @@ -266,6 +313,11 @@ object UiWaitUtils { params.description?.let { "description='$it'" } ).joinToString(", ") + /** + * Compatibility wrapper for existing callers using the old `waitUntilElementGone` API. + * + * Internally delegates to [waitUntilGoneOrThrow]. + */ fun waitUntilElementGone( device: UiDevice, selector: UiSelector, @@ -282,6 +334,11 @@ object UiWaitUtils { @Suppress("MagicNumber") object WaitUtils { + /** + * Legacy fixed sleep helper used by existing test flows. + * + * Kept for compatibility in PR-1; call sites migrate in later cleanups. + */ fun waitFor(seconds: Int, startPinging: () -> Unit = {}, stopPinging: () -> Unit = {}) { if (seconds > 20) { startPinging() @@ -294,6 +351,9 @@ object UiWaitUtils { } } + /** + * Compatibility wrapper for older callers. Uses [waitUntilVisibleOrThrow] internally. + */ fun waitUntilVisible( params: UiSelectorParams, timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, @@ -306,6 +366,9 @@ object UiWaitUtils { ) } + /** + * Waits until a toast containing [message] is visible. + */ fun waitUntilToastIsDisplayed( message: String, timeoutMs: Long = 5_000 @@ -317,6 +380,9 @@ object UiWaitUtils { ) } + /** + * Waits until a system message containing [message] is visible. + */ fun iSeeSystemMessage( message: String, timeoutMs: Long = 5_000 @@ -328,6 +394,11 @@ object UiWaitUtils { ) } + /** + * Asserts a toast with [text] is emitted while executing [trigger]. + * + * This uses accessibility events and is useful when UI tree based lookup is not reliable. + */ @Suppress("MagicNumber") fun assertToastDisplayed(text: String, trigger: () -> Unit, timeoutMs: Long = 5_000L) { var toastDisplayed = false From ed656699e62b5a541e8c6f1dbb3e710a8fa4df57 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Fri, 24 Apr 2026 14:02:03 +0200 Subject: [PATCH 3/6] fix: remove unused --- tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index 8be2a684155..e5d51b29ec7 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -28,9 +28,11 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until +import junit.framework.TestCase.assertTrue +import uiautomatorutils.UiWaitUtils.waitUntilGoneOrThrow +import uiautomatorutils.UiWaitUtils.waitUntilVisibleOrThrow import java.io.IOException import java.util.regex.Pattern -import junit.framework.TestCase.assertTrue private const val TIMEOUT_IN_MILLISECONDS = 10000L private const val DEFAULT_POLLING_INTERVAL_MS = 200L @@ -155,7 +157,6 @@ object UiWaitUtils { timeoutMs: Long = TIMEOUT_IN_MILLISECONDS, pollingIntervalMs: Long = DEFAULT_POLLING_INTERVAL_MS ): UiObject2? { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) var found: UiObject2? = null val isFound = retryUntilTimeout( From ab028b6a44f6c9f7595161c97f689186d76d3a93 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Fri, 24 Apr 2026 14:06:54 +0200 Subject: [PATCH 4/6] fix: migrate current after rebase --- .../criticalFlows/FileSharingBetweenTeams.kt | 4 ++-- .../tests/core/criticalFlows/GroupMessaging.kt | 4 ++-- .../tests/core/criticalFlows/GroupVideoCall.kt | 6 +++--- .../core/criticalFlows/NewMemberMessaging.kt | 4 ++-- .../criticalFlows/PersonalAccountLifeCycle.kt | 6 +++--- .../core/criticalFlows/SSODeviceBackup.kt | 8 ++++---- .../core/pages/ConnectedUserProfilePage.kt | 15 +++++---------- .../tests/core/pages/ConversationViewPage.kt | 12 ++++++------ .../core/tests/PersonalUserRegistrationTest.kt | 4 ++-- .../src/main/uiautomatorutils/UiWaitUtils.kt | 18 ++++++++++++++++-- 10 files changed, 45 insertions(+), 36 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 be7583b26b6..897778cefa7 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 @@ -40,7 +40,7 @@ import user.utils.ClientUser 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 import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed @RunWith(AndroidJUnit4::class) @@ -200,7 +200,7 @@ class FileSharingBetweenTeams : BaseUiTest() { step("Play audio message and verify playback time progresses") { pages.conversationViewPage.apply { clickPlayButtonOnAudioMessage() - waitFor(10) + UiWaitUtils.waitFor(10) clickPauseButtonOnAudioMessage() assertAudioTimeIsNotZeroAnymore() } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt index 5f1457b42b0..bd57a4c84f5 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupMessaging.kt @@ -32,7 +32,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.koin.test.inject import service.TestServiceHelper -import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils import user.usermanager.ClientUserManager import user.utils.ClientUser import com.wire.android.tests.core.BaseUiTest @@ -196,7 +196,7 @@ class GroupMessaging : BaseUiTest() { step("Verify self-deleting message expires and expiration note is shown") { pages.conversationViewPage.apply { - waitFor(14) // Simple wait + UiWaitUtils.waitFor(14) // Simple wait assertMessageNotVisible("This is a Self deleting Message") assertSentMessageIsVisibleInCurrentConversation( "After one participant has seen your message and the timer has expired on their side, this note disappears." diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt index e4fbf7f5d76..8aa8ed38e31 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/GroupVideoCall.kt @@ -40,7 +40,7 @@ import org.koin.test.inject import service.TestServiceHelper import uiautomatorutils.KeyboardUtils.closeKeyboardIfOpened import uiautomatorutils.PermissionUtils.grantRuntimePermsForForegroundApp -import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils import uiautomatorutils.UiWaitUtils.assertToastDisplayed import uiautomatorutils.UiWaitUtils.iSeeSystemMessage import uiautomatorutils.UiWaitUtils.waitUntilToastIsDisplayed @@ -431,14 +431,14 @@ class GroupVideoCall : BaseUiTest() { step("And I play audio message") { pages.conversationViewPage.apply { - waitFor(1) + UiWaitUtils.waitFor(1) clickPlayButtonOnAudioMessage() } } step("And I pause audio message after 5 seconds") { pages.conversationViewPage.apply { - waitFor(5) + UiWaitUtils.waitFor(5) clickPauseButtonOnAudioMessage() } } diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt index dc9a929571f..790e6c6adae 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/NewMemberMessaging.kt @@ -39,7 +39,7 @@ import kotlin.getValue 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 @RunWith(AndroidJUnit4::class) class NewMemberMessaging : BaseUiTest() { @@ -161,7 +161,7 @@ class NewMemberMessaging : BaseUiTest() { } pages.conversationListPage.apply { - waitFor(1) + UiWaitUtils.waitFor(1) clickCloseButtonOnNewConversationScreen() assertConversationListVisible() } 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 26fe5364222..206e8e39f75 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 @@ -33,7 +33,7 @@ import org.junit.Before import org.junit.Test import org.koin.test.inject import service.TestServiceHelper -import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils import user.usermanager.ClientUserManager import user.utils.ClientUser import kotlin.getValue @@ -174,7 +174,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { val user = teamHelper.usersManager.findUserByNameOrNameAlias("user1Name") backendClient.acceptAllIncomingConnectionRequests(user) } - waitFor(1) + UiWaitUtils.waitFor(1) pages.conversationListPage.apply { assertPendingStatusIsNoLongerVisible() tapConversationNameInConversationList(teamOwner?.name ?: "") @@ -234,7 +234,7 @@ class PersonalAccountLifeCycle : BaseUiTest() { } } - waitFor(1) + UiWaitUtils.waitFor(1) step("Verify personal account details in settings") { pages.settingsPage.apply { tapAccountDetailsButton() diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt index 5371070c864..ca35e4de54e 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt @@ -37,7 +37,7 @@ import org.junit.Before import org.junit.Test import org.koin.test.inject import service.TestServiceHelper -import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils import user.usermanager.ClientUserManager import user.utils.ClientUser import kotlin.getValue @@ -105,7 +105,7 @@ class SSODeviceBackup : BaseUiTest() { step("Get SSO code and wait for Okta app assignment sync") { val ssoCode = SSOServiceHelper.getSSOCode() - waitFor(20) // Delay added to allow Okta app assignment to fully sync and avoid 403 error + UiWaitUtils.waitFor(20) // Delay added to allow Okta app assignment to fully sync and avoid 403 error step("Start SSO login flow using SSO code") { pages.registrationPage.apply { @@ -127,7 +127,7 @@ class SSODeviceBackup : BaseUiTest() { enterOktaEmail(member1?.email ?: "") enterOktaPassword(member1?.password ?: "") tapOktaSignIn() - waitFor(5) // Wait for Okta → Wire auth handoff to finish; otherwise, setting up wire page will not succeed. + UiWaitUtils.waitFor(5) // Wait for Okta → Wire auth handoff to finish; otherwise, setting up wire page will not succeed. } } @@ -234,7 +234,7 @@ class SSODeviceBackup : BaseUiTest() { clickLoginButton() } - waitFor(5) // Wait for Okta → Wire auth handoff to finish; + UiWaitUtils.waitFor(5) // Wait for Okta → Wire auth handoff to finish; } step("Finish login flow after logout (decline share data)") { diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt index ed27395bcfb..aa125b05429 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/pages/ConnectedUserProfilePage.kt @@ -17,10 +17,7 @@ */ package com.wire.android.tests.core.pages -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.Until import uiautomatorutils.UiSelectorParams import uiautomatorutils.UiWaitUtils import kotlin.test.DefaultAsserter.assertTrue @@ -60,13 +57,11 @@ data class ConnectedUserProfilePage(private val device: UiDevice) { expectedMessage: String, timeoutMillis: Long = 5_000 ): ConnectedUserProfilePage { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val selector = By.text(expectedMessage) - val toast = device.wait(Until.findObject(selector), timeoutMillis) - - if (toast == null || toast.visibleBounds.isEmpty) { - throw AssertionError("Toast message '$expectedMessage' was not displayed within ${timeoutMillis}ms.") - } + UiWaitUtils.waitUntilVisibleOrThrow( + params = UiSelectorParams(text = expectedMessage), + timeoutMs = timeoutMillis, + errorMessage = "Toast message '$expectedMessage' was not displayed within ${timeoutMillis}ms." + ) 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 9ef2cb8bf6e..858d4c60515 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 @@ -546,13 +546,13 @@ data class ConversationViewPage(private val device: UiDevice) { timeoutMs = timeoutMs, pollingIntervalMs = 200 ) - 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) + if (mlsMarker != null) { + // MLS banner can appear slightly before the conversation is fully ready for a first outbound message. + if (settleAfterDetectedMs > 0) { + UiWaitUtils.waitForMillis(settleAfterDetectedMs) + } + return this } - return this - } 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/tests/PersonalUserRegistrationTest.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt index 51ca29cdf44..a2b1dc86425 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/tests/PersonalUserRegistrationTest.kt @@ -34,7 +34,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.KeyboardUtils.closeKeyboardIfOpened -import uiautomatorutils.UiWaitUtils.WaitUtils.waitFor +import uiautomatorutils.UiWaitUtils /* This test works on the following conditions: @@ -127,7 +127,7 @@ class PersonalUserRegistrationTest : BaseUiTest() { step("Enter OTP and complete account creation") { pages.registrationPage.apply { enter2FAOnCreatePersonalAccountPage(otp) - waitFor(5) + UiWaitUtils.waitFor(5) assertEnterYourUserNameInfoText() assertUserNameHelpText() diff --git a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt index e5d51b29ec7..6e323caec38 100644 --- a/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt +++ b/tests/testsSupport/src/main/uiautomatorutils/UiWaitUtils.kt @@ -29,8 +29,6 @@ import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import junit.framework.TestCase.assertTrue -import uiautomatorutils.UiWaitUtils.waitUntilGoneOrThrow -import uiautomatorutils.UiWaitUtils.waitUntilVisibleOrThrow import java.io.IOException import java.util.regex.Pattern @@ -352,6 +350,22 @@ object UiWaitUtils { } } + /** + * Preferred entrypoint for fixed waits in UI tests. + * + * Internally delegates to [WaitUtils.waitFor] to keep backward compatibility with existing logic. + */ + fun waitFor(seconds: Int, startPinging: () -> Unit = {}, stopPinging: () -> Unit = {}) { + WaitUtils.waitFor(seconds, startPinging, stopPinging) + } + + /** + * Fixed millisecond sleep used by callers that already compute millisecond values. + */ + fun waitForMillis(milliseconds: Long) { + Thread.sleep(milliseconds) + } + /** * Compatibility wrapper for older callers. Uses [waitUntilVisibleOrThrow] internally. */ From d64d306b70d99be3aebae8b3b534a4ec3129a9dc Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Fri, 24 Apr 2026 14:08:03 +0200 Subject: [PATCH 5/6] fix: migrate current after rebase --- .../tests/core/criticalFlows/SSODeviceBackup.kt | 15 ++++++++------- .../tests/core/pages/ConversationViewPage.kt | 1 - 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt index ca35e4de54e..60b9f052584 100644 --- a/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt +++ b/tests/testsCore/src/androidTest/kotlin/com/wire/android/tests/core/criticalFlows/SSODeviceBackup.kt @@ -17,33 +17,33 @@ */ package com.wire.android.tests.core.criticalFlows +import SSOServiceHelper import SSOServiceHelper.thereIsASSOTeamOwnerForOkta import SSOServiceHelper.userAddsOktaUser import SSOServiceHelper.userXIsMe -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.runner.RunWith import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import backendUtils.BackendClient import backendUtils.team.TeamHelper +import com.wire.android.tests.core.BaseUiTest import com.wire.android.tests.core.pages.AllPages import com.wire.android.tests.support.UiAutomatorSetup +import com.wire.android.tests.support.tags.Category +import com.wire.android.tests.support.tags.TestCaseId import deleteDownloadedFilesContaining import kotlinx.coroutines.runBlocking import okta.OktaApiClient import org.junit.After import org.junit.Before import org.junit.Test +import org.junit.runner.RunWith import org.koin.test.inject import service.TestServiceHelper import uiautomatorutils.UiWaitUtils import user.usermanager.ClientUserManager import user.utils.ClientUser -import kotlin.getValue -import com.wire.android.tests.core.BaseUiTest -import com.wire.android.tests.support.tags.Category -import com.wire.android.tests.support.tags.TestCaseId @RunWith(AndroidJUnit4::class) class SSODeviceBackup : BaseUiTest() { @@ -127,7 +127,8 @@ class SSODeviceBackup : BaseUiTest() { enterOktaEmail(member1?.email ?: "") enterOktaPassword(member1?.password ?: "") tapOktaSignIn() - UiWaitUtils.waitFor(5) // Wait for Okta → Wire auth handoff to finish; otherwise, setting up wire page will not succeed. + // Wait for Okta → Wire auth handoff to finish; otherwise, setting up wire page will not succeed. + UiWaitUtils.waitFor(5) } } 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 858d4c60515..f28001ee723 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,7 +17,6 @@ */ 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 From 5be799a61e5f791445017f44d47632f266550336 Mon Sep 17 00:00:00 2001 From: yamilmedina Date: Fri, 24 Apr 2026 14:21:22 +0200 Subject: [PATCH 6/6] fix: add small check to avoid custom sleeps on testcore --- .../main/kotlin/scripts/quality.gradle.kts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/scripts/quality.gradle.kts b/buildSrc/src/main/kotlin/scripts/quality.gradle.kts index 4a67f2f0385..05182d0029f 100644 --- a/buildSrc/src/main/kotlin/scripts/quality.gradle.kts +++ b/buildSrc/src/main/kotlin/scripts/quality.gradle.kts @@ -21,6 +21,7 @@ package scripts import findVersion import io.gitlab.arturbosch.detekt.Detekt import io.gitlab.arturbosch.detekt.DetektCreateBaselineTask +import java.util.regex.Pattern plugins { id("com.android.application") apply false @@ -39,6 +40,7 @@ dependencies { // Detekt Configuration val detektAll by tasks.registering(Detekt::class) { + dependsOn("enforceUiWaitUtilsUsage") // todo. move later to wire-detekt-rules to enforce it from there. group = "Quality" description = "Runs a detekt code analysis ruleset on the Wire Android codebase" parallel = true @@ -84,6 +86,50 @@ tasks.register("staticCodeAnalysis") { dependsOn(detektAll) } +val enforceUiWaitUtilsUsage by tasks.registering { + group = "Quality" + description = "Fails if testsCore uses direct sleep/wait APIs instead of UiWaitUtils." + + doLast { + val root = rootProject.projectDir + val targets = fileTree(root.resolve("tests/testsCore/src/androidTest/kotlin")) { + include("**/*.kt") + } + + val forbiddenPatterns = listOf( + Pattern.compile("""\bThread\.sleep\s*\(""") to "Use UiWaitUtils.waitFor(...) or UiWaitUtils.waitForMillis(...)", + Pattern.compile("""\bSystemClock\.sleep\s*\(""") to "Use UiWaitUtils retry/wait helpers instead of direct sleeps", + Pattern.compile("""\bwaitForExists\s*\(""") to "Use UiWaitUtils.waitUntilVisibleOrThrow(...) or waitUntilGoneOrThrow(...)", + Pattern.compile("""\bUiWaitUtils\.WaitUtils\.waitFor\s*\(""") to "Use UiWaitUtils.waitFor(...)", + Pattern.compile("""import\s+uiautomatorutils\.UiWaitUtils\.WaitUtils\.waitFor""") to "Import UiWaitUtils and call UiWaitUtils.waitFor(...)" + ) + + val violations = mutableListOf() + + targets.files.sortedBy { it.path }.forEach { file -> + val lines = file.readLines() + lines.forEachIndexed { index, line -> + forbiddenPatterns.forEach { (pattern, guidance) -> + if (pattern.matcher(line).find()) { + val relativePath = file.relativeTo(root).path + violations += "$relativePath:${index + 1}: $guidance\n $line" + } + } + } + } + + if (violations.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("UiWaitUtils policy violations found in testsCore:") + appendLine() + appendLine(violations.joinToString("\n")) + } + ) + } + } +} + tasks.register("testCoverage") { group = "Quality" description = "Reports code coverage on tests within the Wire Android codebase." @@ -94,7 +140,8 @@ tasks.register("testCoverage") { if (name == "app") { dependsOn(":app:testDevDebugUnitTest") } else if (validSubprojects.contains(parent?.name) && - !pluginManager.hasPlugin("com.android.kotlin.multiplatform.library")) { + !pluginManager.hasPlugin("com.android.kotlin.multiplatform.library") + ) { dependsOn(":${parent?.name}:$name:testDebugUnitTest") } }