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..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 @@ -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 @@ -120,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 @@ -283,7 +303,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,10 +318,11 @@ 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) - Composer.sendButton.findObject().click() + typeText("/giphy $giphyMessageText") + tapOnComposerConfirmButton() } else { sendMessage("/giphy $giphyMessageText") } 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 f09771090c2..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 @@ -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,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.wait +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 @@ -51,11 +52,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.waitDisplayed(timeOutMillis)) } else { - assertFalse(selector.waitToDisappear().isDisplayed()) + 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 { @@ -91,25 +92,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 +125,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 } @@ -214,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()) @@ -243,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 } @@ -265,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 } @@ -300,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()) } @@ -311,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 { @@ -393,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()) @@ -433,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()) @@ -451,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 { @@ -464,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-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") { 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( 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 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 275abbc484b..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 @@ -28,13 +28,19 @@ public fun sleep(timeOutMillis: Long = defaultTimeout) { /** * Waits up to [timeOutMillis] for an object matching this selector and returns it. * + * 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. */ 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 +51,51 @@ 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", + ) +} + +/** + * Waits up to [timeOutMillis] for an object matching this selector to be displayed and reports + * 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`. + */ +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) { + null +} + +private fun BySelector.currentObjectsOrEmpty(): List = try { + device.findObjects(this) +} catch (_: StaleObjectException) { + emptyList() } public fun BySelector.wait(timeOutMillis: Long = defaultTimeout): BySelector { @@ -66,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. @@ -93,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.