Skip to content

Commit f01dc58

Browse files
authored
Harden E2E UiAutomator text-wait helpers against recomposition (#6383)
* 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. * Throw descriptive error from waitToAppear on timeout. waitToAppear and waitToAppear(withIndex) previously NPE'd via the findObject() extension's non-null declaration when the selector didn't match before timeout. Now they throw IllegalStateException with the selector, timeout, and (for the indexed overload) matched object count. * Fix stale channel-preview test to match post-#6212 behavior. Since #6212 (2026-03-05), the channel preview shows "Message deleted" when the last message is soft-deleted instead of falling back to the previous message. Rename the test, update the assertion, and add a dedicated assertDeletedMessageInChannelPreview helper. * Use waitForText for remaining text asserts and throttle its poll loop.
1 parent 6d5d6c1 commit f01dc58

4 files changed

Lines changed: 70 additions & 32 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: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,8 @@ fun UserRobot.assertMessageFailedIcon(isDisplayed: Boolean): UserRobot {
119119

120120
fun UserRobot.assertEditedMessage(text: String): UserRobot {
121121
assertMessage(text)
122-
assertEquals(
123-
appContext.getString(R.string.stream_compose_message_list_footnote_edited),
124-
Message.editedLabel.waitToAppear().text,
125-
)
122+
val expectedLabel = appContext.getString(R.string.stream_compose_message_list_footnote_edited)
123+
assertEquals(expectedLabel, Message.editedLabel.waitForText(expectedLabel))
126124
return this
127125
}
128126

@@ -143,7 +141,7 @@ fun UserRobot.assertDeletedMessage(text: String? = null, hard: Boolean = false):
143141
fun UserRobot.assertQuotedMessage(text: String, quote: String = "", isDisplayed: Boolean = true): UserRobot {
144142
val quotedMessageInList = Message.quotedMessage.hasAncestor(MessageListPage.MessageList.messages)
145143
if (isDisplayed) {
146-
assertEquals(quote, quotedMessageInList.waitToAppear().waitForText(quote).text)
144+
assertEquals(quote, quotedMessageInList.waitForText(quote))
147145
} else {
148146
assertFalse(quotedMessageInList.waitToDisappear().isDisplayed())
149147
}
@@ -231,14 +229,13 @@ fun UserRobot.assertMentionWasApplied(): UserRobot {
231229
val additionalSpace = " "
232230
val userName = ParticipantRobot.name
233231
val expectedText = "@${userName}$additionalSpace"
234-
val actualText = Composer.inputField.findObject().waitForText(expectedText).text
232+
val actualText = Composer.inputField.waitForText(expectedText)
235233
assertEquals(expectedText, actualText)
236234
return this
237235
}
238236

239237
fun UserRobot.assertComposerText(expectedText: String): UserRobot {
240-
val actualText = Composer.inputField.waitToAppear().text
241-
assertEquals(expectedText, actualText)
238+
assertEquals(expectedText, Composer.inputField.waitForText(expectedText))
242239
return this
243240
}
244241

@@ -268,27 +265,20 @@ fun UserRobot.assertThreadReplyLabelOnParentMessage(): UserRobot {
268265
1,
269266
1,
270267
)
271-
assertEquals(
272-
expectedResult,
273-
Message.threadRepliesLabel.waitToAppear().text,
274-
)
268+
assertEquals(expectedResult, Message.threadRepliesLabel.waitForText(expectedResult))
275269
assertTrue(Message.threadParticipantAvatar.isDisplayed())
276270
return this
277271
}
278272

279273
fun UserRobot.assertAlsoInTheChannelLabelInChannel(): UserRobot {
280-
assertEquals(
281-
appContext.getString(R.string.stream_compose_replied_to_thread),
282-
Message.messageHeaderLabel.waitToAppear().text,
283-
)
274+
val expectedLabel = appContext.getString(R.string.stream_compose_replied_to_thread)
275+
assertEquals(expectedLabel, Message.messageHeaderLabel.waitForText(expectedLabel))
284276
return this
285277
}
286278

287279
fun UserRobot.assertAlsoInTheChannelLabelInThread(): UserRobot {
288-
assertEquals(
289-
appContext.getString(R.string.stream_compose_also_sent_to_channel),
290-
Message.messageHeaderLabel.waitToAppear().text,
291-
)
280+
val expectedLabel = appContext.getString(R.string.stream_compose_also_sent_to_channel)
281+
assertEquals(expectedLabel, Message.messageHeaderLabel.waitForText(expectedLabel))
292282
return this
293283
}
294284

@@ -352,15 +342,15 @@ fun UserRobot.assertThreadReplyLabel(replies: Int, inThread: Boolean = false): U
352342
)
353343
assertEquals(
354344
expectedResult,
355-
ThreadPage.ThreadList.repliesCountLabel.waitToAppear().waitForText(expectedResult).text,
345+
ThreadPage.ThreadList.repliesCountLabel.waitForText(expectedResult),
356346
)
357347
} else {
358348
val expectedResult = appContext.resources.getQuantityString(
359349
R.plurals.stream_compose_message_list_thread_footnote,
360350
replies,
361351
replies,
362352
)
363-
assertEquals(expectedResult, Message.threadRepliesLabel.waitToAppear().text)
353+
assertEquals(expectedResult, Message.threadRepliesLabel.waitForText(expectedResult))
364354
}
365355
return this
366356
}

stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,6 @@ class QuotedReplyTests : StreamTestCase() {
560560
}
561561

562562
@AllureId("5898")
563-
@Ignore("https://linear.app/stream/issue/AND-1136")
564563
@Test
565564
fun test_quotedReplyInThreadAndAlsoInChannel() {
566565
val quotedText = messagesCount.toString()

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

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,41 @@
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

2324
public fun sleep(timeOutMillis: Long = defaultTimeout) {
2425
Thread.sleep(timeOutMillis)
2526
}
2627

28+
/**
29+
* Waits up to [timeOutMillis] for an object matching this selector and returns it.
30+
*
31+
* @param timeOutMillis Maximum time to wait before failing.
32+
* @throws IllegalStateException when the timeout elapses without a matching object.
33+
*/
2734
public fun BySelector.waitToAppear(timeOutMillis: Long = defaultTimeout): UiObject2 {
2835
wait(timeOutMillis)
29-
return findObject()
36+
return device.findObject(this)
37+
?: error("waitToAppear timed out after ${timeOutMillis}ms; no object matched selector: $this")
3038
}
3139

40+
/**
41+
* Waits up to [timeOutMillis] for objects matching this selector and returns the one at [withIndex].
42+
*
43+
* @param withIndex The zero-based index of the object to return.
44+
* @param timeOutMillis Maximum time to wait before failing.
45+
* @throws IllegalStateException when the timeout elapses without enough matching objects.
46+
*/
3247
public fun BySelector.waitToAppear(withIndex: Int, timeOutMillis: Long = defaultTimeout): UiObject2 {
3348
wait(timeOutMillis)
34-
return findObjects()[withIndex]
49+
val objects = device.findObjects(this)
50+
return objects.getOrNull(withIndex)
51+
?: error(
52+
"waitToAppear(withIndex=$withIndex) timed out after ${timeOutMillis}ms; " +
53+
"only ${objects.size} objects matched selector: $this",
54+
)
3555
}
3656

3757
public fun BySelector.wait(timeOutMillis: Long = defaultTimeout): BySelector {
@@ -44,17 +64,43 @@ public fun BySelector.waitToDisappear(timeOutMillis: Long = defaultTimeout): ByS
4464
return this
4565
}
4666

47-
public fun UiObject2.waitForText(
67+
/**
68+
* Waits for an object matching this selector whose text matches [expectedText]. Returns the
69+
* matched text, or the last observed text on timeout. Never throws — recompositions and
70+
* mid-poll node recycling are absorbed internally, so callers should wrap the result in an
71+
* assertion to surface mismatch/timeout.
72+
*
73+
* @param expectedText The text to match.
74+
* @param mustBeEqual When `true`, requires exact match; otherwise a substring match.
75+
* @param timeOutMillis Maximum time to keep polling before returning the last observed text.
76+
*/
77+
public fun BySelector.waitForText(
4878
expectedText: String,
4979
mustBeEqual: Boolean = true,
5080
timeOutMillis: Long = defaultTimeout,
51-
): UiObject2 {
81+
): String {
5282
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)
83+
var lastText = ""
84+
while (System.currentTimeMillis() < endTime) {
85+
val actual = currentTextOrNull()
86+
if (actual != null) {
87+
lastText = actual
88+
val matches = if (mustBeEqual) actual == expectedText else actual.contains(expectedText)
89+
if (matches) return actual
90+
}
91+
Thread.sleep(POLL_INTERVAL_MILLIS)
5692
}
57-
return this
93+
return lastText
94+
}
95+
96+
private const val POLL_INTERVAL_MILLIS = 50L
97+
98+
// Call [device] directly — [findObject] lies about nullability and NPEs when the selector hasn't
99+
// matched yet, which is the normal case during polling.
100+
private fun BySelector.currentTextOrNull(): String? = try {
101+
device.findObject(this)?.text
102+
} catch (_: StaleObjectException) {
103+
null
58104
}
59105

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

0 commit comments

Comments
 (0)