Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion buildSrc/src/main/kotlin/scripts/quality.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<String>()

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."
Expand All @@ -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")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -161,7 +161,7 @@ class NewMemberMessaging : BaseUiTest() {
}

pages.conversationListPage.apply {
waitFor(1)
UiWaitUtils.waitFor(1)
clickCloseButtonOnNewConversationScreen()
assertConversationListVisible()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ?: "")
Expand Down Expand Up @@ -234,7 +234,7 @@ class PersonalAccountLifeCycle : BaseUiTest() {
}
}

waitFor(1)
UiWaitUtils.waitFor(1)
step("Verify personal account details in settings") {
pages.settingsPage.apply {
tapAccountDetailsButton()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.WaitUtils.waitFor
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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -127,7 +127,8 @@ 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.
// Wait for Okta → Wire auth handoff to finish; otherwise, setting up wire page will not succeed.
UiWaitUtils.waitFor(5)
}
}

Expand Down Expand Up @@ -234,7 +235,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)") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading