Skip to content

Harden E2E UiAutomator text-wait helpers against recomposition#6383

Merged
andremion merged 5 commits intodevelopfrom
chore/fix-e2e-tests
Apr 27, 2026
Merged

Harden E2E UiAutomator text-wait helpers against recomposition#6383
andremion merged 5 commits intodevelopfrom
chore/fix-e2e-tests

Conversation

@andremion
Copy link
Copy Markdown
Contributor

@andremion andremion commented Apr 24, 2026

Goal

Compose-driven nodes get recycled mid-poll, causing waitForText to hit StaleObjectException or NPE and fail tests deterministically on slow emulators. Tighten the helpers so assertions survive recomposition and surface timeouts with a clear error.

Implementation

stream-chat-android-e2e-test / Wait.kt

  • waitForText moved from UiObject2 receiver to BySelector. Re-resolves the node on every iteration via device.findObject(this), swallows StaleObjectException as "no match yet", sleeps 50 ms per poll, and returns the matched String (or last observed text on timeout).
  • waitToAppear (both overloads) now throws a descriptive IllegalStateException on timeout instead of leaking a null NPE or ArrayIndexOutOfBoundsException.
  • KDoc added describing throw/stale semantics.

stream-chat-android-compose-sample / robots

  • Call sites in UserRobotChannelListAsserts and UserRobotMessageListAsserts migrated to the new BySelector.waitForText(…) signature, dropping the prior waitToAppear().text pattern that was susceptible to the same recomposition races.

APIwaitForText receiver and return type changed (UiObject2 → BySelector, UiObject2 → String). Internal to the test-infra module; all in-repo consumers migrated.

Testing

E2E run required (device/emulator). On stream-chat-android-compose-sample with the mock server:

  1. Recomposition resilience — run the message-list asserts (assertEditedMessage, assertQuotedMessage, assertMentionWasApplied, assertThreadReplyLabel) and ChannelListTests.test_channelPreviewShowsPreviousMessage_whenLastMessageIsDeleted. Expect: no intermittent StaleObjectException in logcat; previously flaky suites pass consistently.
  2. Timeout surfaces cleanly — temporarily break a selector (e.g. wrong resource id) at any waitToAppear call site and re-run. Expect: IllegalStateException: waitToAppear timed out after 10000ms; no object matched selector: … instead of an NPE.

Follow-up tasks (out of scope here)

Separate PRs planned under refactor/e2e-uiautomator-helpers and perf/e2e-test-runtime:

  • BySelector.wait() discards its boolean — either return it or throw on timeout; migrate call sites that currently do .wait().isDisplayed().
  • Apply the same stale-guard + poll sleep to sister helpers in Wait.kt (waitForCount, waitForTextToChange) and to UiObject2.isDisplayed() in Element.kt.
  • assertSystemMessage currently discards isDisplayed() — add the missing assertTrue/assertFalse.
  • openChannel hits ArrayIndexOutOfBoundsException when the channel list hasn't loaded; switch to waitToAppear(withIndex = …).
  • Scroll helpers (scrollUp/DownUntilDisplayed) NPE on miss — replace with descriptive error.
  • Replace fixed sleep(500/1000/5000) with predicate waits where possible; reduce defaultTimeout from 10 s and add an explicit long timeout for known-slow ops.
  • Route typing through UiObject2.typeText (direct .text =) instead of adb shell input text where the field is addressable — the shell path types character-by-character and dominates runtime for long strings.
  • RetryRule attachment recording fires per failed attempt — record only the final attempt.

No UI changes.

Summary by CodeRabbit

  • Tests
    • Improved assertion reliability for channel message previews with enhanced text matching logic.
    • Added test coverage for deleted message placeholders displayed in channel preview lists.
    • Refined UI automation wait mechanisms with more accurate text polling and explicit timeout error messages.

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.
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.
@andremion andremion added pr:internal Internal changes / housekeeping pr:ignore-for-release Exclude from release notes labels Apr 24, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled.

🎉 Great job! This PR is ready for review.

@andremion
Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.82 MB 5.82 MB 0.00 MB 🟢
stream-chat-android-ui-components 11.02 MB 11.02 MB 0.00 MB 🟢
stream-chat-android-compose 12.33 MB 12.33 MB 0.00 MB 🟢

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 24, 2026

Walkthrough

The changes refactor E2E test infrastructure to improve message preview assertions and enhance the underlying UI automation wait mechanisms. Test assertions are updated to use non-strict text matching for deleted message previews, and the waitForText utility is modified to return matched text strings instead of UI objects with improved error handling.

Changes

Cohort / File(s) Summary
Test Assertion Helpers
stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt, UserRobotMessageListAsserts.kt
Updated assertions to use non-strict text matching and refactored multiple robot assertions to rely on waitForText for expected text. Added new assertDeletedMessageInChannelPreview() helper to verify deleted message placeholders using substring matching.
E2E Test Updates
stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt
Renamed test method to test_channelPreviewShowsDeletedPlaceholder_whenLastMessageIsDeleted and updated final assertion to verify deleted-message placeholder appearance instead of previous message preview.
UI Automation Core
stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt
Enhanced waitToAppear with explicit timeout failure messages. Changed waitForText return type from UiObject2 to String with continuous text polling, immediate match detection, and StaleObjectException handling to prevent crashes during polling.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 With whiskers twitched and careful hops,
We wait for texts and deleted props,
Non-strict matching, strings returned with care,
E2E tests now flow beyond compare!
Stale exceptions caught mid-stride,
This rabbit's changes, tested with pride. 🧪

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main goal: hardening E2E UiAutomator text-wait helpers against node recomposition (StaleObjectException) issues.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR description is comprehensive and well-structured, covering goal, implementation details, testing approach, and follow-up tasks with sufficient technical depth.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/fix-e2e-tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt (2)

67-104: Polling + stale-absorbing text wait — looks correct.

The re-resolve-on-each-poll strategy with StaleObjectException swallowed inside currentTextOrNull() is the right fix for recomposition-driven staleness, and returning the last observed text keeps timeout diagnostics informative when the caller wraps the result in assertEquals.

Two minor observations:

  • The loop shape is check → sleep, so a match that lands during the final 50 ms Thread.sleep window is not observed before returning lastText. Harmless in practice given defaultTimeout, but if you ever tighten timeouts you may want a final post-loop check.
  • When the node never materialises at all, lastText stays as the initial "", which is indistinguishable from a node that legitimately had empty text. For a future iteration, returning String? (null = never observed) would make assertion failures slightly easier to read. Not blocking.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt`
around lines 67 - 104, Add a final post-loop check in BySelector.waitForText to
catch a match that may appear during the last Thread.sleep window: after the
while loop and before returning lastText, call currentTextOrNull(), update
lastText if non-null, and if it matches (respecting mustBeEqual/expectedText)
return it; use the existing currentTextOrNull() helper and keep the function
signature unchanged (POLL_INTERVAL_MILLIS can remain as-is).

28-55: Descriptive errors on timeout — nice upgrade from implicit NPE/AIOOBE.

The error(...) messages include selector, timeout, and (for the indexed variant) matched count, which will make flaky-test triage much easier.

One small gotcha worth keeping in mind (not introduced by this PR): wait(...) delegates to Until.hasObject(this), which only waits for at least one match. For waitToAppear(withIndex = N) with N > 0, a slow emulator that has rendered only the first item will fall through to findObjects() immediately, get fewer than N+1 items, and throw — even though more matches may arrive shortly after. If you see this pattern in E2E flakes, consider extending wait to poll for findObjects().size > withIndex in the indexed overload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt`
around lines 28 - 55, The indexed overload
BySelector.waitToAppear(withIndex:Int, timeOutMillis:Long) currently calls
wait(timeOutMillis) which only guarantees at least one match; change it to
actively poll until device.findObjects(this).size > withIndex or the timeout
elapses (use elapsed time/timestamps to enforce timeOutMillis) and then return
the object at withIndex or throw the descriptive error; keep the non-indexed
variant as-is and reuse device.findObjects(this) for the size check so
slow-rendering additional matches are waited for instead of immediately failing.
stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt (1)

174-200: Rename and assertion update accurately reflect post-#6212 behavior.

Keeping the same @AllureId("5821") preserves traceability while the semantic has flipped from "previous message shown" to "deleted placeholder shown". The variable rename (previousMessage/lastMessage) reads clearly against the deletion semantics.

Optional: since the name emphasises "not the previous message", you could additionally assert the preview no longer contains previousMessage (e.g. via a negative variant of assertMessageInChannelPreview). Not required — assertDeletedMessageInChannelPreview() already captures the critical expectation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt`
around lines 174 - 200, Update the test to reflect the new behavior after `#6212`:
rename the message variables to clarify roles (use previousMessage and
lastMessage) and replace the old channel-preview assertion with
userRobot.assertDeletedMessageInChannelPreview() in
test_channelPreviewShowsDeletedPlaceholder_whenLastMessageIsDeleted; optionally
add a negative assertion (e.g., assertMessageInChannelPreview does not contain
previousMessage) if you want to ensure the previous message is not shown.
stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt (2)

34-45: mustBeEqual = false + assertEquals pairing is subtle — worth a comment.

The substring poll lets waitForText return as soon as the preview contains expectedPreview (tolerating trailing whitespace or late-appended characters), then trimEnd() + assertEquals enforces the stricter shape. It's correct, but a drive-by reader could reasonably expect "non-strict wait → non-strict assert". A one-liner comment explaining "substring match to absorb trailing whitespace from composer/preview layout; strict comparison after trimEnd()" would save future-you a double-take. No behavior change required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt`
around lines 34 - 45, In UserRobot.assertMessageInChannelPreview, add a one-line
comment above the wait/assert sequence explaining that
Channel.messagePreview.waitForText is called with mustBeEqual = false to allow a
substring match (to absorb trailing whitespace or late-appended characters from
the composer/preview layout), and then trimEnd() + assertEquals enforces the
strict equality; reference the call sites
Channel.messagePreview.waitForText(mustBeEqual = false) and the following
trimEnd() + assertEquals to make the intent explicit for future readers.

47-56: Use R.string.stream_compose_message_deleted_preview for the deleted-message preview text.

Sibling assertions (assertEditedMessage, assertAlsoInTheChannelLabelInChannel) use appContext.getString(R.string.*) to source expected text, ensuring the test automatically tracks any string or localisation changes. Replace the hardcoded DELETED_PREVIEW_TEXT constant with:

val expectedLabel = appContext.getString(R.string.stream_compose_message_deleted_preview)

This keeps the test in sync with the resource and avoids silent desync if the string is renamed or translated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt`
around lines 47 - 56, The test currently uses a hardcoded DELETED_PREVIEW_TEXT
in assertDeletedMessageInChannelPreview; replace that constant with the resource
string fetched from the app context
(R.string.stream_compose_message_deleted_preview) and use that fetched value
(e.g., expectedLabel =
appContext.getString(R.string.stream_compose_message_deleted_preview)) when
calling Channel.messagePreview.waitForText and in the assertion message so the
test follows localization and resource changes; update or remove the
DELETED_PREVIEW_TEXT constant and adjust the
assertDeletedMessageInChannelPreview function accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt`:
- Around line 34-45: In UserRobot.assertMessageInChannelPreview, add a one-line
comment above the wait/assert sequence explaining that
Channel.messagePreview.waitForText is called with mustBeEqual = false to allow a
substring match (to absorb trailing whitespace or late-appended characters from
the composer/preview layout), and then trimEnd() + assertEquals enforces the
strict equality; reference the call sites
Channel.messagePreview.waitForText(mustBeEqual = false) and the following
trimEnd() + assertEquals to make the intent explicit for future readers.
- Around line 47-56: The test currently uses a hardcoded DELETED_PREVIEW_TEXT in
assertDeletedMessageInChannelPreview; replace that constant with the resource
string fetched from the app context
(R.string.stream_compose_message_deleted_preview) and use that fetched value
(e.g., expectedLabel =
appContext.getString(R.string.stream_compose_message_deleted_preview)) when
calling Channel.messagePreview.waitForText and in the assertion message so the
test follows localization and resource changes; update or remove the
DELETED_PREVIEW_TEXT constant and adjust the
assertDeletedMessageInChannelPreview function accordingly.

In
`@stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt`:
- Around line 174-200: Update the test to reflect the new behavior after `#6212`:
rename the message variables to clarify roles (use previousMessage and
lastMessage) and replace the old channel-preview assertion with
userRobot.assertDeletedMessageInChannelPreview() in
test_channelPreviewShowsDeletedPlaceholder_whenLastMessageIsDeleted; optionally
add a negative assertion (e.g., assertMessageInChannelPreview does not contain
previousMessage) if you want to ensure the previous message is not shown.

In
`@stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt`:
- Around line 67-104: Add a final post-loop check in BySelector.waitForText to
catch a match that may appear during the last Thread.sleep window: after the
while loop and before returning lastText, call currentTextOrNull(), update
lastText if non-null, and if it matches (respecting mustBeEqual/expectedText)
return it; use the existing currentTextOrNull() helper and keep the function
signature unchanged (POLL_INTERVAL_MILLIS can remain as-is).
- Around line 28-55: The indexed overload BySelector.waitToAppear(withIndex:Int,
timeOutMillis:Long) currently calls wait(timeOutMillis) which only guarantees at
least one match; change it to actively poll until device.findObjects(this).size
> withIndex or the timeout elapses (use elapsed time/timestamps to enforce
timeOutMillis) and then return the object at withIndex or throw the descriptive
error; keep the non-indexed variant as-is and reuse device.findObjects(this) for
the size check so slow-rendering additional matches are waited for instead of
immediately failing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f82506c2-5eec-4f66-aff2-5059a4a15589

📥 Commits

Reviewing files that changed from the base of the PR and between 217d159 and ecbe0ae.

📒 Files selected for processing (4)
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotMessageListAsserts.kt
  • stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt
  • stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt

@andremion andremion marked this pull request as ready for review April 24, 2026 08:58
@andremion andremion requested a review from a team as a code owner April 24, 2026 08:58
@andremion andremion enabled auto-merge (squash) April 24, 2026 08:59
@andremion andremion force-pushed the chore/fix-e2e-tests branch from ecbe0ae to 77017b5 Compare April 24, 2026 09:42
Copy link
Copy Markdown
Contributor

@VelikovPetar VelikovPetar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that we should be able to uncomment the following test after merging this: test_quotedReplyInThreadAndAlsoInChannel

@andremion
Copy link
Copy Markdown
Contributor Author

I believe that we should be able to uncomment the following test after merging this: test_quotedReplyInThreadAndAlsoInChannel

Let's see: a9d51bd

@andremion andremion requested a review from VelikovPetar April 27, 2026 10:50
@sonarqubecloud
Copy link
Copy Markdown

@andremion andremion merged commit f01dc58 into develop Apr 27, 2026
16 checks passed
@andremion andremion deleted the chore/fix-e2e-tests branch April 27, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:ignore-for-release Exclude from release notes pr:internal Internal changes / housekeeping

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants