From f304893784979038724729fb7597ada83448859a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 14:51:27 +0100 Subject: [PATCH 01/10] e2e: Poll findObject in waitToAppear to survive recompositions waitToAppear waited for hasObject once and then read findObject once. Under active recomposition the node the wait just saw can be recycled before the single read reaches it, failing in a few hundred ms with a misleading timeout message; the failure screenshots show the app in the correct state. Both overloads now poll at the existing 50ms interval within the same timeout budget and absorb StaleObjectException between polls. --- .../chat/android/e2e/test/uiautomator/Wait.kt | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index 275abbc484b..d9a4c7550fe 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -28,13 +28,21 @@ public fun sleep(timeOutMillis: Long = defaultTimeout) { /** * Waits up to [timeOutMillis] for an object matching this selector and returns it. * + * Repeatedly reads [findObject] on an interval rather than waiting once and reading once: under + * active recomposition the node the wait just saw can be recycled before a single read reaches + * it, which surfaced as spurious "timed out" failures within a few hundred ms. Stale reads are + * absorbed and retried. + * * @param timeOutMillis Maximum time to wait before failing. * @throws IllegalStateException when the timeout elapses without a matching object. */ public fun BySelector.waitToAppear(timeOutMillis: Long = defaultTimeout): UiObject2 { - wait(timeOutMillis) - return device.findObject(this) - ?: error("waitToAppear timed out after ${timeOutMillis}ms; no object matched selector: $this") + val endTime = System.currentTimeMillis() + timeOutMillis + while (System.currentTimeMillis() < endTime) { + currentObjectOrNull()?.let { return it } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + error("waitToAppear timed out after ${timeOutMillis}ms; no object matched selector: $this") } /** @@ -45,13 +53,30 @@ public fun BySelector.waitToAppear(timeOutMillis: Long = defaultTimeout): UiObje * @throws IllegalStateException when the timeout elapses without enough matching objects. */ public fun BySelector.waitToAppear(withIndex: Int, timeOutMillis: Long = defaultTimeout): UiObject2 { - wait(timeOutMillis) - val objects = device.findObjects(this) - return objects.getOrNull(withIndex) - ?: error( - "waitToAppear(withIndex=$withIndex) timed out after ${timeOutMillis}ms; " + - "only ${objects.size} objects matched selector: $this", - ) + val endTime = System.currentTimeMillis() + timeOutMillis + var lastCount = 0 + while (System.currentTimeMillis() < endTime) { + val objects = currentObjectsOrEmpty() + lastCount = objects.size + objects.getOrNull(withIndex)?.let { return it } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + error( + "waitToAppear(withIndex=$withIndex) timed out after ${timeOutMillis}ms; " + + "only $lastCount objects matched selector: $this", + ) +} + +private fun BySelector.currentObjectOrNull(): UiObject2? = try { + device.findObject(this) +} catch (_: StaleObjectException) { + null +} + +private fun BySelector.currentObjectsOrEmpty(): List = try { + device.findObjects(this) +} catch (_: StaleObjectException) { + emptyList() } public fun BySelector.wait(timeOutMillis: Long = defaultTimeout): BySelector { From 7ab3fddf0ea9450562d7029f7bb852ddec94c56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 14:51:27 +0100 Subject: [PATCH 02/10] e2e: Route delivery status asserts through the polling visibility helper assertMessageDeliveryStatus and assertMessageFailedIcon used wait().isDisplayed(), the same wait-once-read-once race in a second shape; the historic failures of test_deliveryStatusShownForTheLastMessageInGroup died in 460-572ms. assertVisibility gains a timeout parameter (the READ branch keeps its 30s) and these asserts go through it. On a genuine timeout they now surface as IllegalStateException (Allure broken) instead of AssertionError (failed). --- .../robots/UserRobotMessageListAsserts.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt index f09771090c2..f2152af1de2 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.getstream.chat.android.e2e.test.mockserver.ReactionType import io.getstream.chat.android.e2e.test.robots.ParticipantRobot import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.e2e.test.uiautomator.defaultTimeout import io.getstream.chat.android.e2e.test.uiautomator.device import io.getstream.chat.android.e2e.test.uiautomator.findObject import io.getstream.chat.android.e2e.test.uiautomator.findObjects @@ -36,7 +37,6 @@ import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.isEnabled import io.getstream.chat.android.e2e.test.uiautomator.retryOnStaleObjectException import io.getstream.chat.android.e2e.test.uiautomator.seconds -import io.getstream.chat.android.e2e.test.uiautomator.wait import io.getstream.chat.android.e2e.test.uiautomator.waitForCount import io.getstream.chat.android.e2e.test.uiautomator.waitForText import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear @@ -51,11 +51,11 @@ import org.junit.Assert.assertTrue * Asserts the selector's visibility with a bounded wait: waits for it to appear when * [isDisplayed] is `true`, or to disappear when `false`, then asserts the final state. */ -private fun assertVisibility(selector: BySelector, isDisplayed: Boolean) { +private fun assertVisibility(selector: BySelector, isDisplayed: Boolean, timeOutMillis: Long = defaultTimeout) { if (isDisplayed) { - assertTrue(selector.waitToAppear().isDisplayed()) + assertTrue(selector.waitToAppear(timeOutMillis).isDisplayed()) } else { - assertFalse(selector.waitToDisappear().isDisplayed()) + assertFalse(selector.waitToDisappear(timeOutMillis).isDisplayed()) } } @@ -91,25 +91,25 @@ fun UserRobot.assertMessageTimestamps(count: Int): UserRobot { fun UserRobot.assertMessageDeliveryStatus(status: MessageDeliveryStatus, count: Int? = null): UserRobot { when (status) { MessageDeliveryStatus.READ -> { - assertTrue(Message.deliveryStatusIsRead.wait(30.seconds).isDisplayed()) + assertVisibility(Message.deliveryStatusIsRead, isDisplayed = true, timeOutMillis = 30.seconds) if (count != null) { assertEquals(count, Message.deliveryStatusIsRead.waitForCount(count).size) } } MessageDeliveryStatus.PENDING -> { - assertTrue(Message.deliveryStatusIsPending.wait().isDisplayed()) + assertVisibility(Message.deliveryStatusIsPending, isDisplayed = true) if (count != null) { assertEquals(count, Message.deliveryStatusIsPending.waitForCount(count).size) } } MessageDeliveryStatus.SENT -> { - assertTrue(Message.deliveryStatusIsSent.wait().isDisplayed()) + assertVisibility(Message.deliveryStatusIsSent, isDisplayed = true) if (count != null) { assertEquals(count, Message.deliveryStatusIsSent.waitForCount(count).size) } } MessageDeliveryStatus.FAILED -> { - assertTrue(Message.deliveryStatusIsFailed.wait().isDisplayed()) + assertVisibility(Message.deliveryStatusIsFailed, isDisplayed = true) if (count != null) { assertEquals(count, Message.deliveryStatusIsFailed.waitForCount(count).size) } @@ -124,11 +124,7 @@ fun UserRobot.assertMessageDeliveryStatus(status: MessageDeliveryStatus, count: } fun UserRobot.assertMessageFailedIcon(isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(Message.deliveryStatusIsFailed.wait().isDisplayed()) - } else { - assertFalse(Message.deliveryStatusIsFailed.waitToDisappear().isDisplayed()) - } + assertVisibility(Message.deliveryStatusIsFailed, isDisplayed) return this } From d36b426f5cb6bb59ebce5ee60a5d4315ef74888a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 14:51:27 +0100 Subject: [PATCH 03/10] e2e: Record by method name and stop recordings without failing the test Back-ports from stream-video-android PR 1746: the recording file uses methodName (displayName's parentheses break when recording commands go through the device shell, which depends on the uiautomator version), and a recording stop failure logs and skips the video attachment instead of replacing the test result or skipping the failure reporting. --- .../chat/android/e2e/test/rules/RetryRule.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt index bedca8cd3a7..a31db6799a1 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/rules/RetryRule.kt @@ -53,7 +53,10 @@ public class RetryRule(private val count: Int) : TestRule { for (attempt in 1..count) { val recordVideo = attempt > 1 - val videoFilePath = "${Environment.getExternalStorageDirectory().absolutePath}/$testName.mp4" + // methodName, not displayName: the display name contains parentheses, which + // break when the recording commands go through the device shell. + val videoFilePath = + "${Environment.getExternalStorageDirectory().absolutePath}/${description.methodName}.mp4" var recordingThread: Thread? = null val startMillis = System.currentTimeMillis() try { @@ -63,17 +66,17 @@ public class RetryRule(private val count: Int) : TestRule { recordingThread = startVideoRecording(videoFilePath) } base.evaluate() - recordingThread?.let { stopVideoRecording(videoFilePath, it) } + recordingThread?.let { stopRecordingSafely(testName, videoFilePath, it) } return } catch (t: Throwable) { System.err.println("$testName: run #$attempt failed.") caughtThrowable = t databaseOperations.clearDatabases() - recordingThread?.let { stopVideoRecording(videoFilePath, it) } + val recordingStopped = recordingThread?.let { stopRecordingSafely(testName, videoFilePath, it) } device.allureLogcat(name = "logcat_$attempt") device.allureScreenshot(name = "screenshot_$attempt") device.allureWindowHierarchy(name = "hierarchy_$attempt") - recordingThread?.let { + if (recordingStopped == true) { device.allureScreenrecord(name = "record_$attempt", file = File(videoFilePath)) } if (attempt < count) { @@ -136,6 +139,16 @@ public class RetryRule(private val count: Int) : TestRule { lifecycle.writeTestCase(attemptResult.uuid) } + /** + * Stops the recording without failing the test: a recording infrastructure problem must + * not change the test result or skip the failure reporting. Returns whether the recording + * was stopped and its file is usable. + */ + private fun stopRecordingSafely(testName: String, videoFilePath: String, thread: Thread): Boolean = + runCatching { stopVideoRecording(videoFilePath, thread) } + .onFailure { System.err.println("$testName: stopping the screen recording failed: $it") } + .isSuccess + private fun startVideoRecording(remoteVideoPath: String): Thread { return Thread { device.executeShellCommand( From eb851c07c376c67eb1f59dba5637906fe7372e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 14:51:27 +0100 Subject: [PATCH 04/10] e2e: Re-enable the composer giphy test on the redesigned commands flow test_userObservesAnimatedGiphy_afterAddingGiphyThroughComposerMenu was ignored pending the composer redesign (AND-1018), which shipped in January. The redesign removed the dedicated commands button, so the flow now types '/' to open the command suggestion list. Text entry uses the direct UiObject2.typeText: the shell-based input leaks its wrapping quotes into the field on this uiautomator version (the composer literally contained '/'), which also silently affected the command branch of uploadGiphy. The dead Stream_ComposerCommandsButton selector is removed. --- .../chat/android/compose/pages/MessageListPage.kt | 1 - .../getstream/chat/android/compose/robots/UserRobot.kt | 9 ++++++--- .../getstream/chat/android/compose/tests/GiphyTests.kt | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt index e4c79d7a529..c0b71038429 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/MessageListPage.kt @@ -65,7 +65,6 @@ open class MessageListPage { val cooldownIndicator get() = By.res("Stream_ComposerCooldownIndicator") val saveButton get() = By.res("Stream_ComposerSaveButton") val recordAudioButton get() = By.res("Stream_ComposerAudioRecordingButton") - val commandsButton get() = By.res("Stream_ComposerCommandsButton") val commandSuggestionList get() = By.res("Stream_CommandSuggestionList") val commandSuggestionListTitle get() = By.res("Stream_CommandSuggestionListTitle") val userSuggestion get() = By.res("Stream_SuggestionItem") diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index c3bb8eca577..e476687db3a 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -283,7 +283,9 @@ class UserRobot { } fun openComposerCommands(): UserRobot { - Composer.commandsButton.waitToAppear().click() + // The composer redesign removed the dedicated commands button; typing '/' in the + // input field opens the command suggestion list. + typeText("/") return this } @@ -296,9 +298,10 @@ class UserRobot { val giphyMessageText = "G" // any message text will result in sending a giphy if (useComposerCommand) { openComposerCommands() + // Selecting the suggestion prefills '/giphy '; typeText replaces the whole input, + // so the command prefix is set together with the message text. Composer.giphyButton.waitToAppear().click() - Composer.inputField.findObject().click() - device.typeText(giphyMessageText) + typeText("/giphy $giphyMessageText") Composer.sendButton.findObject().click() } else { sendMessage("/giphy $giphyMessageText") diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GiphyTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GiphyTests.kt index aec3313c242..965e0a20886 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GiphyTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GiphyTests.kt @@ -154,7 +154,6 @@ class GiphyTests : StreamTestCase() { } @AllureId("5823") - @Ignore("https://linear.app/stream/issue/AND-1018") @Test fun test_userObservesAnimatedGiphy_afterAddingGiphyThroughComposerMenu() { step("GIVEN user opens a channel") { From 142287130bcb02372ce64685fd72c4447b99230b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 14:51:27 +0100 Subject: [PATCH 05/10] e2e: Remove the shell-based typeText helper No usages remain, and on this uiautomator version the shell command's wrapping quotes reach the input field as literal text. Direct UiObject2.typeText is the working path everywhere. --- .../io/getstream/chat/android/e2e/test/uiautomator/Actions.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt index ab525d98cbd..0b0f6fa85e9 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt @@ -29,10 +29,6 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File -public fun UiDevice.typeText(text: String) { - executeShellCommand("input text '$text'") -} - public fun UiObject2.typeText(text: String): UiObject2 { this.text = text return this From af792e0943b43ea50b71f44b0a993c330e9dd84d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 15:17:31 +0100 Subject: [PATCH 06/10] e2e: Wait for the send button in the giphy command flow The command branch of uploadGiphy tapped the send button with a one-shot findObject right after setting the input text. The send button only renders once the input is non-empty, and on a slow CI emulator the recomposition can still be in flight, so the read returned null and all three attempts failed. tapOnSendButton already waits and is what every other send path uses. --- .../io/getstream/chat/android/compose/robots/UserRobot.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index e476687db3a..f4f54dff992 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -31,7 +31,6 @@ import io.getstream.chat.android.e2e.test.mockserver.ReactionType import io.getstream.chat.android.e2e.test.robots.ParticipantRobot import io.getstream.chat.android.e2e.test.uiautomator.defaultTimeout import io.getstream.chat.android.e2e.test.uiautomator.device -import io.getstream.chat.android.e2e.test.uiautomator.findObject import io.getstream.chat.android.e2e.test.uiautomator.findObjects import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.longPress @@ -302,7 +301,7 @@ class UserRobot { // so the command prefix is set together with the message text. Composer.giphyButton.waitToAppear().click() typeText("/giphy $giphyMessageText") - Composer.sendButton.findObject().click() + tapOnSendButton() } else { sendMessage("/giphy $giphyMessageText") } From 254ca3ebca8fcd3f2efbe8edbf83d68cb9f65e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 15:50:56 +0100 Subject: [PATCH 07/10] e2e: Tap the composer confirm button shown after selecting a command Selecting a command suggestion activates command mode, where the composer's trailing button is the save button instead of the send button. Whether the suggestion tap lands depends on the animation state (CI disables animations, local emulators usually do not), so the giphy command flow saw the send button locally and the save button on CI. With the sample's configuration both buttons build the same message, so the robot taps whichever the composer settled on. --- .../chat/android/compose/robots/UserRobot.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index f4f54dff992..2a2cfba045b 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -119,6 +119,27 @@ class UserRobot { return this } + /** + * Taps whichever confirm button the composer is showing. Selecting a command suggestion + * activates command mode, where the trailing button is the save button instead of the + * send button; with the sample's configuration both build the same message. + */ + private fun tapOnComposerConfirmButton(): UserRobot { + val endTime = System.currentTimeMillis() + defaultTimeout + while (System.currentTimeMillis() < endTime) { + Composer.sendButton.findObjects().firstOrNull()?.let { + it.click() + return this + } + Composer.saveButton.findObjects().firstOrNull()?.let { + it.click() + return this + } + Thread.sleep(50) + } + error("Neither the send nor the save composer button appeared within ${defaultTimeout}ms") + } + fun tapOnLinkPreviewCancelButton(): UserRobot { Composer.linkPreviewCancelButton.waitToAppear().click() return this @@ -301,7 +322,7 @@ class UserRobot { // so the command prefix is set together with the message text. Composer.giphyButton.waitToAppear().click() typeText("/giphy $giphyMessageText") - tapOnSendButton() + tapOnComposerConfirmButton() } else { sendMessage("/giphy $giphyMessageText") } From b25d956cf039201b4bb53b87c3de6e9e36d2234c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 13 Jul 2026 16:22:41 +0100 Subject: [PATCH 08/10] e2e: Assert visibility through a selector-level polling predicate A node returned by waitToAppear can be recycled by recomposition before the caller reads visibleCenter, so assertTrue(waitToAppear().isDisplayed()) still threw StaleObjectException after the wait itself was fixed. waitDisplayed polls find-and-check as one operation and reports the outcome, so asserts never hold a node across reads. All 14 assert sites move to it; assertGiphyImage's stale-retry wrapper becomes redundant. waitToAppear stays for interaction flows that need the node. --- .../robots/UserRobotChannelListAsserts.kt | 4 +-- .../robots/UserRobotMessageListAsserts.kt | 25 +++++++++---------- .../chat/android/compose/tests/AuthTests.kt | 6 ++--- .../chat/android/e2e/test/uiautomator/Wait.kt | 21 ++++++++++++++++ 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt index 4653bcd0a66..98981966cde 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt @@ -19,8 +19,8 @@ package io.getstream.chat.android.compose.robots import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelList.Channel import io.getstream.chat.android.e2e.test.robots.ParticipantRobot import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitForText -import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -46,7 +46,7 @@ fun UserRobot.assertMessageInChannelPreview(text: String, fromCurrentUser: Boole fun UserRobot.assertMessagePreviewTimestamp(isDisplayed: Boolean = true): UserRobot { if (isDisplayed) { - assertTrue(Channel.timestamp.waitToAppear().isDisplayed()) + assertTrue(Channel.timestamp.waitDisplayed()) } else { assertFalse(Channel.timestamp.waitToDisappear().isDisplayed()) } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt index f2152af1de2..4471b6d66f7 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.isEnabled import io.getstream.chat.android.e2e.test.uiautomator.retryOnStaleObjectException import io.getstream.chat.android.e2e.test.uiautomator.seconds +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitForCount import io.getstream.chat.android.e2e.test.uiautomator.waitForText import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear @@ -53,7 +54,7 @@ import org.junit.Assert.assertTrue */ private fun assertVisibility(selector: BySelector, isDisplayed: Boolean, timeOutMillis: Long = defaultTimeout) { if (isDisplayed) { - assertTrue(selector.waitToAppear(timeOutMillis).isDisplayed()) + assertTrue(selector.waitDisplayed(timeOutMillis)) } else { assertFalse(selector.waitToDisappear(timeOutMillis).isDisplayed()) } @@ -67,7 +68,7 @@ fun UserRobot.assertMessage( if (isDisplayed) { val textLocator = (if (isClickable) Message.clickableText else Message.text) .text(text) - assertTrue(textLocator.waitToAppear().isDisplayed()) + assertTrue(textLocator.waitDisplayed()) assertTrue(Message.timestamp.isDisplayed()) } else { MessageListPage.MessageList.messages.findObjects().forEach { @@ -210,7 +211,7 @@ fun UserRobot.assertAttachmentsMenu(isDisplayed: Boolean): UserRobot { fun UserRobot.assertComposerCommandsMenu(isDisplayed: Boolean): UserRobot { if (isDisplayed) { - assertTrue(Composer.commandSuggestionList.waitToAppear().isDisplayed()) + assertTrue(Composer.commandSuggestionList.waitDisplayed()) assertTrue(Composer.commandSuggestionListTitle.isDisplayed()) } else { assertFalse(Composer.commandSuggestionList.waitToDisappear().isDisplayed()) @@ -239,7 +240,7 @@ fun UserRobot.assertComposerText(expectedText: String): UserRobot { } fun UserRobot.assertCooldownIsShown(): UserRobot { - assertTrue(Composer.cooldownIndicator.waitToAppear().isDisplayed()) + assertTrue(Composer.cooldownIndicator.waitDisplayed()) assertFalse(Composer.sendButton.isDisplayed()) return this } @@ -261,7 +262,7 @@ fun UserRobot.assertScrollToBottomButton(isDisplayed: Boolean): UserRobot { } fun UserRobot.assertThreadIsOpen(): UserRobot { - assertTrue(ThreadPage.ThreadList.alsoSendToChannelCheckbox.waitToAppear().isDisplayed()) + assertTrue(ThreadPage.ThreadList.alsoSendToChannelCheckbox.waitDisplayed()) return this } @@ -296,9 +297,7 @@ fun UserRobot.assertAlsoInTheChannelLabelInThread(): UserRobot { fun UserRobot.assertGiphyImage(isDisplayed: Boolean = true): UserRobot { if (isDisplayed) { - device.retryOnStaleObjectException { - assertTrue(Message.giphy.waitToAppear().isDisplayed()) - } + assertTrue(Message.giphy.waitDisplayed()) } else { assertFalse(Message.giphy.waitToDisappear().isDisplayed()) } @@ -307,7 +306,7 @@ fun UserRobot.assertGiphyImage(isDisplayed: Boolean = true): UserRobot { fun UserRobot.assertGiphyButtons(areDisplayed: Boolean = true): UserRobot { if (areDisplayed) { - assertTrue(Message.GiphyButtons.send.waitToAppear().isDisplayed()) + assertTrue(Message.GiphyButtons.send.waitDisplayed()) assertTrue(Message.GiphyButtons.cancel.findObject().isDisplayed()) assertTrue(Message.GiphyButtons.shuffle.findObject().isDisplayed()) } else { @@ -389,7 +388,7 @@ fun UserRobot.assertVideo(isDisplayed: Boolean, count: Int = 1): UserRobot { if (isDisplayed) { assertEquals(count, Message.video.waitForCount(count).size) if (count != 1) { - assertTrue(Message.columnWithMultipleMediaAttachments.waitToAppear().isDisplayed()) + assertTrue(Message.columnWithMultipleMediaAttachments.waitDisplayed()) } } else { assertFalse(Message.video.waitToDisappear().isDisplayed()) @@ -429,7 +428,7 @@ fun UserRobot.assertMediaAttachmentInPreview(isDisplayed: Boolean, count: Int = fun UserRobot.assertFileAttachmentInPreview(isDisplayed: Boolean, count: Int = 1): UserRobot { if (isDisplayed) { - assertTrue(Composer.fileName.waitToAppear().isDisplayed()) + assertTrue(Composer.fileName.waitDisplayed()) assertTrue(Composer.fileSize.isDisplayed()) assertTrue(Composer.fileImage.isDisplayed()) assertTrue(Composer.attachmentCancelIcon.isDisplayed()) @@ -447,7 +446,7 @@ fun UserRobot.assertFileAttachmentInPreview(isDisplayed: Boolean, count: Int = 1 fun UserRobot.assertLinkPreviewInMessageList(isDisplayed: Boolean): UserRobot { if (isDisplayed) { - assertTrue(Message.linkPreviewImage.waitToAppear().isDisplayed()) + assertTrue(Message.linkPreviewImage.waitDisplayed()) assertTrue(Message.linkPreviewTitle.isDisplayed()) assertTrue(Message.linkPreviewDescription.isDisplayed()) } else { @@ -460,7 +459,7 @@ fun UserRobot.assertLinkPreviewInMessageList(isDisplayed: Boolean): UserRobot { fun UserRobot.assertLinkPreviewInComposer(isDisplayed: Boolean): UserRobot { if (isDisplayed) { - assertTrue(Composer.linkPreviewImage.waitToAppear().isDisplayed()) + assertTrue(Composer.linkPreviewImage.waitDisplayed()) assertTrue(Composer.linkPreviewTitle.isDisplayed()) assertTrue(Composer.linkPreviewDescription.isDisplayed()) } else { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AuthTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AuthTests.kt index c013f31efa6..fee5d756ba0 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AuthTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AuthTests.kt @@ -24,8 +24,8 @@ import io.getstream.chat.android.e2e.test.uiautomator.disableInternetConnection import io.getstream.chat.android.e2e.test.uiautomator.enableInternetConnection import io.getstream.chat.android.e2e.test.uiautomator.goToBackground import io.getstream.chat.android.e2e.test.uiautomator.goToForeground -import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.seconds +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId @@ -238,12 +238,12 @@ class AuthTests : StreamTestCase() { } private fun UserRobot.assertConnectionStatus(): UserRobot { - assertTrue(JwtPage.statusConnected.waitToAppear(15.seconds).isDisplayed()) + assertTrue(JwtPage.statusConnected.waitDisplayed(15.seconds)) return this } private fun UserRobot.assertTokenHasExpired(): UserRobot { - assertTrue(JwtPage.statusOffline.waitToAppear(15.seconds).isDisplayed()) + assertTrue(JwtPage.statusOffline.waitDisplayed(15.seconds)) return this } diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index d9a4c7550fe..cc05f5a7b57 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -67,6 +67,27 @@ public fun BySelector.waitToAppear(withIndex: Int, timeOutMillis: Long = default ) } +/** + * Waits up to [timeOutMillis] for an object matching this selector to be displayed and reports + * the outcome. The visibility check happens inside the poll: reads on a returned node race + * recomposition, because the node can be recycled between the wait and the read. + * + * @param timeOutMillis Maximum time to keep polling before reporting `false`. + */ +public fun BySelector.waitDisplayed(timeOutMillis: Long = defaultTimeout): Boolean { + val endTime = System.currentTimeMillis() + timeOutMillis + while (System.currentTimeMillis() < endTime) { + try { + if (device.findObject(this)?.isDisplayed() == true) { + return true + } + } catch (_: StaleObjectException) { + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + return false +} + private fun BySelector.currentObjectOrNull(): UiObject2? = try { device.findObject(this) } catch (_: StaleObjectException) { From 14eb1ebffb64f3e089459e7c32d41eea511aa01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 14 Jul 2026 09:06:22 +0100 Subject: [PATCH 09/10] e2e: Trim the wait helper KDocs to the contract --- .../chat/android/e2e/test/uiautomator/Wait.kt | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index cc05f5a7b57..727f04fb657 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -28,10 +28,8 @@ public fun sleep(timeOutMillis: Long = defaultTimeout) { /** * Waits up to [timeOutMillis] for an object matching this selector and returns it. * - * Repeatedly reads [findObject] on an interval rather than waiting once and reading once: under - * active recomposition the node the wait just saw can be recycled before a single read reaches - * it, which surfaced as spurious "timed out" failures within a few hundred ms. Stale reads are - * absorbed and retried. + * Stale reads during the lookup are absorbed and retried until the timeout; + * [StaleObjectException] never escapes. * * @param timeOutMillis Maximum time to wait before failing. * @throws IllegalStateException when the timeout elapses without a matching object. @@ -69,8 +67,8 @@ public fun BySelector.waitToAppear(withIndex: Int, timeOutMillis: Long = default /** * Waits up to [timeOutMillis] for an object matching this selector to be displayed and reports - * the outcome. The visibility check happens inside the poll: reads on a returned node race - * recomposition, because the node can be recycled between the wait and the read. + * the outcome. Stale reads during the lookup are absorbed and retried until the timeout; + * [StaleObjectException] never escapes. * * @param timeOutMillis Maximum time to keep polling before reporting `false`. */ @@ -112,9 +110,9 @@ public fun BySelector.waitToDisappear(timeOutMillis: Long = defaultTimeout): ByS /** * Waits for an object matching this selector whose text matches [expectedText]. Returns the - * matched text, or the last observed text on timeout. Never throws — recompositions and - * mid-poll node recycling are absorbed internally, so callers should wrap the result in an - * assertion to surface mismatch/timeout. + * matched text, or the last observed text on timeout. Never throws: stale reads are absorbed + * and retried, so callers should wrap the result in an assertion to surface a mismatch or + * timeout. * * @param expectedText The text to match. * @param mustBeEqual When `true`, requires exact match; otherwise a substring match. From f6517661a5db8052266934064e6a01eb21507047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 14 Jul 2026 09:06:22 +0100 Subject: [PATCH 10/10] e2e: Absorb stale reads in the bottom-up message lookup --- .../e2e/test/uiautomator/VisualOrder.kt | 20 ++++++++++++++++--- .../chat/android/e2e/test/uiautomator/Wait.kt | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt index 9285a49c4ea..fbef27be5cd 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.e2e.test.uiautomator import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiObject2 /** @@ -25,11 +26,24 @@ import androidx.test.uiautomator.UiObject2 * (oldest-first), so lookups that mean "index 0 = the newest message at the visual bottom" * sort by on-screen position instead of relying on the enumeration order. * - * @param timeOutMillis Maximum time to wait before returning whatever matched. + * Stale reads during the lookup are absorbed and retried until the timeout; + * [StaleObjectException] never escapes. + * + * @param timeOutMillis Maximum time to wait before returning an empty list. */ public fun BySelector.waitToAppearBottomUp(timeOutMillis: Long = defaultTimeout): List { - wait(timeOutMillis) - return device.findObjects(this).sortedByDescending { it.visibleBounds.top } + val endTime = System.currentTimeMillis() + timeOutMillis + while (System.currentTimeMillis() < endTime) { + try { + val objects = device.findObjects(this) + if (objects.isNotEmpty()) { + return objects.sortedByDescending { it.visibleBounds.top } + } + } catch (_: StaleObjectException) { + } + Thread.sleep(POLL_INTERVAL_MILLIS) + } + return emptyList() } /** diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index 727f04fb657..194ff55514d 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -137,7 +137,7 @@ public fun BySelector.waitForText( return lastText } -private const val POLL_INTERVAL_MILLIS = 50L +internal const val POLL_INTERVAL_MILLIS = 50L // Call [device] directly — [findObject] lies about nullability and NPEs when the selector hasn't // matched yet, which is the normal case during polling.