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 55607cfa434..a6f74ad43f1 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 @@ -37,7 +37,10 @@ fun UserRobot.assertMessageInChannelPreview(text: String, fromCurrentUser: Boole false -> "${ParticipantRobot.name}: $text" null -> text } - assertEquals(expectedPreview, Channel.messagePreview.waitToAppear().waitForText(expectedPreview).text.trimEnd()) + assertEquals( + expectedPreview, + Channel.messagePreview.waitForText(expectedPreview, mustBeEqual = false).trimEnd(), + ) return this } 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 2e5697c8b74..be888f50503 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 @@ -119,10 +119,8 @@ fun UserRobot.assertMessageFailedIcon(isDisplayed: Boolean): UserRobot { fun UserRobot.assertEditedMessage(text: String): UserRobot { assertMessage(text) - assertEquals( - appContext.getString(R.string.stream_compose_message_list_footnote_edited), - Message.editedLabel.waitToAppear().text, - ) + val expectedLabel = appContext.getString(R.string.stream_compose_message_list_footnote_edited) + assertEquals(expectedLabel, Message.editedLabel.waitForText(expectedLabel)) return this } @@ -143,7 +141,7 @@ fun UserRobot.assertDeletedMessage(text: String? = null, hard: Boolean = false): fun UserRobot.assertQuotedMessage(text: String, quote: String = "", isDisplayed: Boolean = true): UserRobot { val quotedMessageInList = Message.quotedMessage.hasAncestor(MessageListPage.MessageList.messages) if (isDisplayed) { - assertEquals(quote, quotedMessageInList.waitToAppear().waitForText(quote).text) + assertEquals(quote, quotedMessageInList.waitForText(quote)) } else { assertFalse(quotedMessageInList.waitToDisappear().isDisplayed()) } @@ -231,14 +229,13 @@ fun UserRobot.assertMentionWasApplied(): UserRobot { val additionalSpace = " " val userName = ParticipantRobot.name val expectedText = "@${userName}$additionalSpace" - val actualText = Composer.inputField.findObject().waitForText(expectedText).text + val actualText = Composer.inputField.waitForText(expectedText) assertEquals(expectedText, actualText) return this } fun UserRobot.assertComposerText(expectedText: String): UserRobot { - val actualText = Composer.inputField.waitToAppear().text - assertEquals(expectedText, actualText) + assertEquals(expectedText, Composer.inputField.waitForText(expectedText)) return this } @@ -268,27 +265,20 @@ fun UserRobot.assertThreadReplyLabelOnParentMessage(): UserRobot { 1, 1, ) - assertEquals( - expectedResult, - Message.threadRepliesLabel.waitToAppear().text, - ) + assertEquals(expectedResult, Message.threadRepliesLabel.waitForText(expectedResult)) assertTrue(Message.threadParticipantAvatar.isDisplayed()) return this } fun UserRobot.assertAlsoInTheChannelLabelInChannel(): UserRobot { - assertEquals( - appContext.getString(R.string.stream_compose_replied_to_thread), - Message.messageHeaderLabel.waitToAppear().text, - ) + val expectedLabel = appContext.getString(R.string.stream_compose_replied_to_thread) + assertEquals(expectedLabel, Message.messageHeaderLabel.waitForText(expectedLabel)) return this } fun UserRobot.assertAlsoInTheChannelLabelInThread(): UserRobot { - assertEquals( - appContext.getString(R.string.stream_compose_also_sent_to_channel), - Message.messageHeaderLabel.waitToAppear().text, - ) + val expectedLabel = appContext.getString(R.string.stream_compose_also_sent_to_channel) + assertEquals(expectedLabel, Message.messageHeaderLabel.waitForText(expectedLabel)) return this } @@ -352,7 +342,7 @@ fun UserRobot.assertThreadReplyLabel(replies: Int, inThread: Boolean = false): U ) assertEquals( expectedResult, - ThreadPage.ThreadList.repliesCountLabel.waitToAppear().waitForText(expectedResult).text, + ThreadPage.ThreadList.repliesCountLabel.waitForText(expectedResult), ) } else { val expectedResult = appContext.resources.getQuantityString( @@ -360,7 +350,7 @@ fun UserRobot.assertThreadReplyLabel(replies: Int, inThread: Boolean = false): U replies, replies, ) - assertEquals(expectedResult, Message.threadRepliesLabel.waitToAppear().text) + assertEquals(expectedResult, Message.threadRepliesLabel.waitForText(expectedResult)) } return this } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt index 84bfefd57d8..4d2e126dfa1 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt @@ -560,7 +560,6 @@ class QuotedReplyTests : StreamTestCase() { } @AllureId("5898") - @Ignore("https://linear.app/stream/issue/AND-1136") @Test fun test_quotedReplyInThreadAndAlsoInChannel() { val quotedText = messagesCount.toString() 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 a6d7e778bef..eff46a82782 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.compose.uiautomator import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.StaleObjectException import androidx.test.uiautomator.UiObject2 import androidx.test.uiautomator.Until @@ -24,14 +25,33 @@ public fun sleep(timeOutMillis: Long = defaultTimeout) { Thread.sleep(timeOutMillis) } +/** + * Waits up to [timeOutMillis] for an object matching this selector and returns it. + * + * @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 findObject() + return device.findObject(this) + ?: error("waitToAppear timed out after ${timeOutMillis}ms; no object matched selector: $this") } +/** + * Waits up to [timeOutMillis] for objects matching this selector and returns the one at [withIndex]. + * + * @param withIndex The zero-based index of the object to return. + * @param timeOutMillis Maximum time to wait before failing. + * @throws IllegalStateException when the timeout elapses without enough matching objects. + */ public fun BySelector.waitToAppear(withIndex: Int, timeOutMillis: Long = defaultTimeout): UiObject2 { wait(timeOutMillis) - return findObjects()[withIndex] + 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", + ) } public fun BySelector.wait(timeOutMillis: Long = defaultTimeout): BySelector { @@ -44,17 +64,43 @@ public fun BySelector.waitToDisappear(timeOutMillis: Long = defaultTimeout): ByS return this } -public fun UiObject2.waitForText( +/** + * 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. + * + * @param expectedText The text to match. + * @param mustBeEqual When `true`, requires exact match; otherwise a substring match. + * @param timeOutMillis Maximum time to keep polling before returning the last observed text. + */ +public fun BySelector.waitForText( expectedText: String, mustBeEqual: Boolean = true, timeOutMillis: Long = defaultTimeout, -): UiObject2 { +): String { val endTime = System.currentTimeMillis() + timeOutMillis - var textPresent = false - while (!textPresent && System.currentTimeMillis() < endTime) { - textPresent = if (mustBeEqual) text == expectedText else text.contains(expectedText) + var lastText = "" + while (System.currentTimeMillis() < endTime) { + val actual = currentTextOrNull() + if (actual != null) { + lastText = actual + val matches = if (mustBeEqual) actual == expectedText else actual.contains(expectedText) + if (matches) return actual + } + Thread.sleep(POLL_INTERVAL_MILLIS) } - return this + return lastText +} + +private 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. +private fun BySelector.currentTextOrNull(): String? = try { + device.findObject(this)?.text +} catch (_: StaleObjectException) { + null } public fun BySelector.waitForCount(count: Int, timeOutMillis: Long = defaultTimeout): List {