Skip to content

Commit 8a8e6db

Browse files
committed
Make Wait.kt's text-wait resilient to recomposition.
Rework UiObject2.waitForText into BySelector.waitForText that re-resolves the selector on each poll, absorbing StaleObjectException from mid-poll recomposition. Bypass the findObject() extension (which lies about nullability) by calling device.findObject(this) directly. Accept a mustBeEqual flag so the channel-preview assertion can substring-match and avoid timing out when the rendered preview has trailing whitespace. Update the 4 robot call sites.
1 parent 217d159 commit 8a8e6db

3 files changed

Lines changed: 37 additions & 10 deletions

File tree

stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ fun UserRobot.assertMessageInChannelPreview(text: String, fromCurrentUser: Boole
3737
false -> "${ParticipantRobot.name}: $text"
3838
null -> text
3939
}
40-
assertEquals(expectedPreview, Channel.messagePreview.waitToAppear().waitForText(expectedPreview).text.trimEnd())
40+
assertEquals(
41+
expectedPreview,
42+
Channel.messagePreview.waitForText(expectedPreview, mustBeEqual = false).trimEnd(),
43+
)
4144
return this
4245
}
4346

stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ fun UserRobot.assertDeletedMessage(text: String? = null, hard: Boolean = false):
143143
fun UserRobot.assertQuotedMessage(text: String, quote: String = "", isDisplayed: Boolean = true): UserRobot {
144144
val quotedMessageInList = Message.quotedMessage.hasAncestor(MessageListPage.MessageList.messages)
145145
if (isDisplayed) {
146-
assertEquals(quote, quotedMessageInList.waitToAppear().waitForText(quote).text)
146+
assertEquals(quote, quotedMessageInList.waitForText(quote))
147147
} else {
148148
assertFalse(quotedMessageInList.waitToDisappear().isDisplayed())
149149
}
@@ -231,7 +231,7 @@ fun UserRobot.assertMentionWasApplied(): UserRobot {
231231
val additionalSpace = " "
232232
val userName = ParticipantRobot.name
233233
val expectedText = "@${userName}$additionalSpace"
234-
val actualText = Composer.inputField.findObject().waitForText(expectedText).text
234+
val actualText = Composer.inputField.waitForText(expectedText)
235235
assertEquals(expectedText, actualText)
236236
return this
237237
}
@@ -352,7 +352,7 @@ fun UserRobot.assertThreadReplyLabel(replies: Int, inThread: Boolean = false): U
352352
)
353353
assertEquals(
354354
expectedResult,
355-
ThreadPage.ThreadList.repliesCountLabel.waitToAppear().waitForText(expectedResult).text,
355+
ThreadPage.ThreadList.repliesCountLabel.waitForText(expectedResult),
356356
)
357357
} else {
358358
val expectedResult = appContext.resources.getQuantityString(

stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package io.getstream.chat.android.compose.uiautomator
1818

1919
import androidx.test.uiautomator.BySelector
20+
import androidx.test.uiautomator.StaleObjectException
2021
import androidx.test.uiautomator.UiObject2
2122
import androidx.test.uiautomator.Until
2223

@@ -44,17 +45,40 @@ public fun BySelector.waitToDisappear(timeOutMillis: Long = defaultTimeout): ByS
4445
return this
4546
}
4647

47-
public fun UiObject2.waitForText(
48+
/**
49+
* Polls by re-finding the object on each iteration so a mid-poll recomposition does not produce
50+
* a [StaleObjectException]. Returns the text that was observed when the match succeeded, or the
51+
* last observed text on timeout — never throws. Callers typically wrap the result in an
52+
* assertion to surface mismatch/timeout.
53+
*
54+
* @param expectedText The text to match.
55+
* @param mustBeEqual When `true`, requires exact match; otherwise a substring match.
56+
* @param timeOutMillis Maximum time to keep polling before returning the last observed text.
57+
*/
58+
public fun BySelector.waitForText(
4859
expectedText: String,
4960
mustBeEqual: Boolean = true,
5061
timeOutMillis: Long = defaultTimeout,
51-
): UiObject2 {
62+
): String {
5263
val endTime = System.currentTimeMillis() + timeOutMillis
53-
var textPresent = false
54-
while (!textPresent && System.currentTimeMillis() < endTime) {
55-
textPresent = if (mustBeEqual) text == expectedText else text.contains(expectedText)
64+
var lastText = ""
65+
while (System.currentTimeMillis() < endTime) {
66+
val actual = currentTextOrNull()
67+
if (actual != null) {
68+
lastText = actual
69+
val matches = if (mustBeEqual) actual == expectedText else actual.contains(expectedText)
70+
if (matches) return actual
71+
}
5672
}
57-
return this
73+
return lastText
74+
}
75+
76+
// Call [device] directly — [findObject] lies about nullability and NPEs when the selector hasn't
77+
// matched yet, which is the normal case during polling.
78+
private fun BySelector.currentTextOrNull(): String? = try {
79+
device.findObject(this)?.text
80+
} catch (_: StaleObjectException) {
81+
null
5882
}
5983

6084
public fun BySelector.waitForCount(count: Int, timeOutMillis: Long = defaultTimeout): List<UiObject2> {

0 commit comments

Comments
 (0)