diff --git a/AGENTS.md b/AGENTS.md index ad99a338f41..f26c1ee1308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,13 @@ Prefer module-scoped tasks while iterating; PRs should pass `spotlessCheck`, `de - For concurrency-sensitive logic (uploads, sync, message state), add deterministic tests using `runTest` + virtual time. - Update baseline profiles (`baseline-prof.txt`) when start-up or scroll performance changes significantly. +### End-to-end (E2E) tests +E2E tests run a sample app against the mock server (`stream-chat-test-mock-server`) with UiAutomator, driven by fastlane and reported to Allure. Code is split by whether it knows the app under test, and the dependency flows one way: the sample depends on the module, never the reverse. +- `stream-chat-android-e2e-test/` holds app-agnostic infrastructure, reusable by any sample: the mock server client, the outside-world robots (`ParticipantRobot` for the second user, `BackendRobot` for server controls like tokens and cooldown), the JUnit `RetryRule`, and the low-level UiAutomator toolkit (device access, waits, selectors). Package root: `io.getstream.chat.android.e2e.test.*`. +- `{app}-sample/src/androidTest/` holds everything tied to that app's UI: the Page Objects (resource-id selectors), the `UserRobot` that drives the app under test plus its assertion extensions, the test classes, and the app-specific `StreamTestCase` base. +- Rule of thumb: new code that references app screens or resource ids goes in the sample; mock-server, lifecycle, or device helpers that any app could reuse go in the module. +- Today only `stream-chat-android-compose-sample` has E2E tests. + ## Documentation & comments - Update module README, `docs/`, or API docs when altering setup, themes, or sample flows. - Log deprecations or behavioural shifts in release notes via appropriate PR labels. diff --git a/fastlane/Allurefile b/fastlane/Allurefile index a9b973e206e..fcc41c18b13 100755 --- a/fastlane/Allurefile +++ b/fastlane/Allurefile @@ -20,11 +20,13 @@ desc 'Create launch on Allure TestOps' lane :allure_launch do |options| next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) + # Only real scheduled runs are named 'Cron checks'. A manual dispatch of the nightly + # workflow falls back to the head commit message, so it stays distinguishable in TestOps. launch_id = allure_create_launch( url: allure_api_url, project_id: allure_project_id, github_run_details: github_run_details, - cron: options[:cron] + cron: options[:cron] && ENV['GITHUB_EVENT_NAME'] == 'schedule' ) sh("echo 'LAUNCH_ID=#{launch_id}' >> $GITHUB_ENV") if is_ci end diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9cd474d7cf6..cbd61f60b70 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -151,7 +151,12 @@ private_lane :batch_tests do |options| install(tool: :test_parser) sh("java -jar test-parser.jar #{options[:test_apk_path]} ./") test_names = File.read('AllTests.txt').split - current_batch = test_names.each_slice((test_names.size.to_f / options[:batch_count].to_i).ceil).to_a[options[:batch].to_i] + batch = options[:batch].to_i + batch_count = options[:batch_count].to_i + # Round-robin split always yields exactly batch_count groups. Slicing by a rounded-up + # size can yield fewer groups than batch_count, making the last batch nil and crashing. + current_batch = test_names.select.with_index { |_, i| i % batch_count == batch } + UI.user_error!("Batch #{batch} is empty: #{test_names.size} tests cannot fill #{batch_count} batches") if current_batch.empty? "-e class #{current_batch.join(',')}" else '' 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 c9fd64b9904..e4c79d7a529 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 @@ -135,21 +135,9 @@ open class MessageListPage { companion object { val reactions get() = By.res("Stream_MessageReaction") - private val like get() = By.res("Stream_MessageReaction_${ReactionType.LIKE.reaction}") - private val love get() = By.res("Stream_MessageReaction_${ReactionType.LOVE.reaction}") - private val lol get() = By.res("Stream_MessageReaction_${ReactionType.LOL.reaction}") - private val wow get() = By.res("Stream_MessageReaction_${ReactionType.WOW.reaction}") - private val sad get() = By.res("Stream_MessageReaction_${ReactionType.SAD.reaction}") - - fun reaction(type: ReactionType): BySelector { - return when (type) { - ReactionType.LIKE -> like - ReactionType.LOVE -> love - ReactionType.LOL -> lol - ReactionType.SAD -> sad - ReactionType.WOW -> wow - } - } + + fun reaction(type: ReactionType): BySelector = + By.res("Stream_MessageReaction_${type.reaction}") } } @@ -182,21 +170,8 @@ open class MessageListPage { class ReactionsView { companion object { - private val like get() = By.res("Stream_Reaction_${ReactionType.LIKE.reaction}") - private val love get() = By.res("Stream_Reaction_${ReactionType.LOVE.reaction}") - private val lol get() = By.res("Stream_Reaction_${ReactionType.LOL.reaction}") - private val wow get() = By.res("Stream_Reaction_${ReactionType.WOW.reaction}") - private val sad get() = By.res("Stream_Reaction_${ReactionType.SAD.reaction}") - - fun reaction(type: ReactionType): BySelector { - return when (type) { - ReactionType.LIKE -> like - ReactionType.LOVE -> love - ReactionType.LOL -> lol - ReactionType.SAD -> sad - ReactionType.WOW -> wow - } - } + fun reaction(type: ReactionType): BySelector = + By.res("Stream_Reaction_${type.reaction}") } } } 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 a07b7e8e8c4..c3bb8eca577 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 @@ -26,28 +26,28 @@ import io.getstream.chat.android.compose.pages.MessageListPage.MessageList import io.getstream.chat.android.compose.pages.MessageListPage.MessageList.Message import io.getstream.chat.android.compose.pages.MessageListPage.MessageList.Message.ContextMenu import io.getstream.chat.android.compose.pages.ThreadPage -import io.getstream.chat.android.compose.uiautomator.defaultTimeout -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.findObject -import io.getstream.chat.android.compose.uiautomator.findObjects -import io.getstream.chat.android.compose.uiautomator.isDisplayed -import io.getstream.chat.android.compose.uiautomator.longPress -import io.getstream.chat.android.compose.uiautomator.swipeDown -import io.getstream.chat.android.compose.uiautomator.swipeUp -import io.getstream.chat.android.compose.uiautomator.tapOnScreenCenter -import io.getstream.chat.android.compose.uiautomator.typeText -import io.getstream.chat.android.compose.uiautomator.wait -import io.getstream.chat.android.compose.uiautomator.waitToAppear -import io.getstream.chat.android.compose.uiautomator.waitToAppearBottomUp -import io.getstream.chat.android.compose.uiautomator.waitToDisappear import io.getstream.chat.android.e2e.test.mockserver.AttachmentType 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 +import io.getstream.chat.android.e2e.test.uiautomator.swipeDown +import io.getstream.chat.android.e2e.test.uiautomator.swipeUp +import io.getstream.chat.android.e2e.test.uiautomator.tapOnScreenCenter +import io.getstream.chat.android.e2e.test.uiautomator.typeText +import io.getstream.chat.android.e2e.test.uiautomator.wait +import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear +import io.getstream.chat.android.e2e.test.uiautomator.waitToAppearBottomUp +import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear class UserRobot { fun sleep(timeOutMillis: Long = defaultTimeout): UserRobot { - io.getstream.chat.android.compose.uiautomator.sleep(timeOutMillis) + io.getstream.chat.android.e2e.test.uiautomator.sleep(timeOutMillis) return this } 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 a6f74ad43f1..4653bcd0a66 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 @@ -17,11 +17,11 @@ package io.getstream.chat.android.compose.robots import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelList.Channel -import io.getstream.chat.android.compose.uiautomator.isDisplayed -import io.getstream.chat.android.compose.uiautomator.waitForText -import io.getstream.chat.android.compose.uiautomator.waitToAppear -import io.getstream.chat.android.compose.uiautomator.waitToDisappear 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.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 import org.junit.Assert.assertTrue 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 1f57af63567..f09771090c2 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 @@ -18,34 +18,47 @@ package io.getstream.chat.android.compose.robots import android.annotation.SuppressLint import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.pages.MessageListPage import io.getstream.chat.android.compose.pages.MessageListPage.Composer import io.getstream.chat.android.compose.pages.MessageListPage.MessageList.Message import io.getstream.chat.android.compose.pages.ThreadPage -import io.getstream.chat.android.compose.uiautomator.appContext -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.findObject -import io.getstream.chat.android.compose.uiautomator.findObjects -import io.getstream.chat.android.compose.uiautomator.height -import io.getstream.chat.android.compose.uiautomator.isDisplayed -import io.getstream.chat.android.compose.uiautomator.isEnabled -import io.getstream.chat.android.compose.uiautomator.retryOnStaleObjectException -import io.getstream.chat.android.compose.uiautomator.seconds -import io.getstream.chat.android.compose.uiautomator.wait -import io.getstream.chat.android.compose.uiautomator.waitForCount -import io.getstream.chat.android.compose.uiautomator.waitForText -import io.getstream.chat.android.compose.uiautomator.waitToAppear -import io.getstream.chat.android.compose.uiautomator.waitToAppearBottomUp -import io.getstream.chat.android.compose.uiautomator.waitToDisappear 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.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.height +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.waitForCount +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.waitToAppearBottomUp +import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals 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) { + if (isDisplayed) { + assertTrue(selector.waitToAppear().isDisplayed()) + } else { + assertFalse(selector.waitToDisappear().isDisplayed()) + } +} + fun UserRobot.assertMessage( text: String, isDisplayed: Boolean = true, @@ -190,20 +203,12 @@ fun UserRobot.assertComposerSize(isChangeable: Boolean): UserRobot { } fun UserRobot.assertTypingIndicator(isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(MessageListPage.MessageList.typingIndicator.waitToAppear().isDisplayed()) - } else { - assertFalse(MessageListPage.MessageList.typingIndicator.waitToDisappear().isDisplayed()) - } + assertVisibility(MessageListPage.MessageList.typingIndicator, isDisplayed) return this } fun UserRobot.assertAttachmentsMenu(isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(MessageListPage.AttachmentPicker.view.waitToAppear().isDisplayed()) - } else { - assertFalse(MessageListPage.AttachmentPicker.view.waitToDisappear().isDisplayed()) - } + assertVisibility(MessageListPage.AttachmentPicker.view, isDisplayed) return this } @@ -219,11 +224,7 @@ fun UserRobot.assertComposerCommandsMenu(isDisplayed: Boolean): UserRobot { } fun UserRobot.assertComposerMentionsMenu(isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(Composer.userSuggestion.waitToAppear().isDisplayed()) - } else { - assertFalse(Composer.userSuggestion.waitToDisappear().isDisplayed()) - } + assertVisibility(Composer.userSuggestion, isDisplayed) return this } @@ -259,11 +260,7 @@ fun UserRobot.assertComposerIsDisabledInSlowMode(): UserRobot { } fun UserRobot.assertScrollToBottomButton(isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(MessageListPage.MessageList.scrollToBottomButton.waitToAppear().isDisplayed()) - } else { - assertFalse(MessageListPage.MessageList.scrollToBottomButton.waitToDisappear().isDisplayed()) - } + assertVisibility(MessageListPage.MessageList.scrollToBottomButton, isDisplayed) return this } @@ -326,11 +323,7 @@ fun UserRobot.assertGiphyButtons(areDisplayed: Boolean = true): UserRobot { } fun UserRobot.assertSystemMessage(text: String, isDisplayed: Boolean = true): UserRobot { - if (isDisplayed) { - By.text(text).waitToAppear().isDisplayed() - } else { - By.text(text).waitToDisappear().isDisplayed() - } + assertVisibility(By.text(text), isDisplayed) return this } @@ -343,11 +336,7 @@ fun UserRobot.assertInvalidCommandMessage(text: String, isDisplayed: Boolean = t } fun UserRobot.assertReaction(type: ReactionType, isDisplayed: Boolean): UserRobot { - if (isDisplayed) { - assertTrue(Message.Reactions.reaction(type).waitToAppear().isDisplayed()) - } else { - assertFalse(Message.Reactions.reaction(type).waitToDisappear().isDisplayed()) - } + assertVisibility(Message.Reactions.reaction(type), isDisplayed) return this } 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 eda23460317..c013f31efa6 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 @@ -19,14 +19,14 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.pages.JwtPage import io.getstream.chat.android.compose.robots.UserRobot import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.disableInternetConnection -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection -import io.getstream.chat.android.compose.uiautomator.goToBackground -import io.getstream.chat.android.compose.uiautomator.goToForeground -import io.getstream.chat.android.compose.uiautomator.isDisplayed -import io.getstream.chat.android.compose.uiautomator.seconds -import io.getstream.chat.android.compose.uiautomator.waitToAppear +import io.getstream.chat.android.e2e.test.uiautomator.device +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.waitToAppear import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Assert.assertTrue diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt index ac57aca1c2a..2fd5a3e5efd 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelListTests.kt @@ -21,11 +21,11 @@ import io.getstream.chat.android.compose.robots.assertMessageDeliveryStatus import io.getstream.chat.android.compose.robots.assertMessageInChannelPreview import io.getstream.chat.android.compose.robots.assertMessagePreviewTimestamp import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.disableInternetConnection -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection -import io.getstream.chat.android.compose.uiautomator.seconds import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus +import io.getstream.chat.android.e2e.test.uiautomator.device +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.seconds import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Ignore diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/DraftMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/DraftMessagesTests.kt index fa49bc65061..62ddddc1be7 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/DraftMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/DraftMessagesTests.kt @@ -19,9 +19,9 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertComposerText import io.getstream.chat.android.compose.robots.assertMessageInChannelPreview import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.disableInternetConnection -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.disableInternetConnection +import io.getstream.chat.android.e2e.test.uiautomator.enableInternetConnection import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Test diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MessageListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MessageListTests.kt index b1f5289d371..f5180c45219 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MessageListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MessageListTests.kt @@ -35,13 +35,13 @@ import io.getstream.chat.android.compose.robots.assertThreadMessage import io.getstream.chat.android.compose.robots.assertThreadReplyLabelOnParentMessage import io.getstream.chat.android.compose.robots.assertTypingIndicator import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.disableInternetConnection -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection -import io.getstream.chat.android.compose.uiautomator.goToBackground -import io.getstream.chat.android.compose.uiautomator.goToForeground import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.getstream.chat.android.e2e.test.mockserver.forbiddenWord +import io.getstream.chat.android.e2e.test.uiautomator.device +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.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Ignore 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 4d2e126dfa1..0916ba8d683 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 @@ -26,8 +26,8 @@ import io.getstream.chat.android.compose.robots.assertScrollToBottomButton import io.getstream.chat.android.compose.robots.assertThreadReplyLabel import io.getstream.chat.android.compose.robots.assertThreadReplyLabelAvatars import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.appContext import io.getstream.chat.android.e2e.test.mockserver.AttachmentType +import io.getstream.chat.android.e2e.test.uiautomator.appContext import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Ignore diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ReactionsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ReactionsTests.kt index af9af25aa77..139c9a41d47 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ReactionsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ReactionsTests.kt @@ -19,12 +19,12 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.pages.MessageListPage.MessageList.Message import io.getstream.chat.android.compose.robots.assertReaction import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.disableInternetConnection -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection -import io.getstream.chat.android.compose.uiautomator.seconds -import io.getstream.chat.android.compose.uiautomator.wait import io.getstream.chat.android.e2e.test.mockserver.ReactionType +import io.getstream.chat.android.e2e.test.uiautomator.device +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.seconds +import io.getstream.chat.android.e2e.test.uiautomator.wait import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Test @@ -101,7 +101,7 @@ class ReactionsTests : StreamTestCase() { userRobot.addReaction(type = ReactionType.LOL) } step("AND user removes the reaction") { - userRobot.deleteReaction(type = ReactionType.LOL).sleep(5000) + userRobot.deleteReaction(type = ReactionType.LOL) } step("THEN the reaction is removed") { userRobot.assertReaction(type = ReactionType.LOL, isDisplayed = false) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt index c9bdb0aac85..dcc0ec69efd 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt @@ -25,14 +25,14 @@ import android.annotation.SuppressLint import android.content.Intent import io.getstream.chat.android.compose.robots.UserRobot import io.getstream.chat.android.compose.sample.ui.InitTestActivity -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.grantPermission -import io.getstream.chat.android.compose.uiautomator.packageName -import io.getstream.chat.android.compose.uiautomator.testContext import io.getstream.chat.android.e2e.test.mockserver.MockServer import io.getstream.chat.android.e2e.test.robots.BackendRobot import io.getstream.chat.android.e2e.test.robots.ParticipantRobot import io.getstream.chat.android.e2e.test.rules.RetryRule +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.grantPermission +import io.getstream.chat.android.e2e.test.uiautomator.packageName +import io.getstream.chat.android.e2e.test.uiautomator.testContext import org.junit.After import org.junit.Before import org.junit.Rule diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt index 84c7bdfacf2..82dcd795fbe 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/mockserver/MockServer.kt @@ -16,8 +16,8 @@ package io.getstream.chat.android.e2e.test.mockserver -import io.getstream.chat.android.compose.uiautomator.device -import io.getstream.chat.android.compose.uiautomator.enableInternetConnection +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.enableInternetConnection import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -25,7 +25,6 @@ import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.ResponseBody -import org.junit.Assert.fail public const val forbiddenWord: String = "wth" @@ -71,8 +70,7 @@ public class MockServer(testName: String) { return okHttp.newCall(request).execute() } catch (e: Exception) { device.enableInternetConnection() - fail(e.message) + throw AssertionError(e.message, e) } - return Response.Builder().build() } } diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt index a42920a02c9..617165af491 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/ParticipantRobot.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.e2e.test.robots -import io.getstream.chat.android.compose.uiautomator.defaultTimeout import io.getstream.chat.android.e2e.test.mockserver.AttachmentType import io.getstream.chat.android.e2e.test.mockserver.MockServer import io.getstream.chat.android.e2e.test.mockserver.ReactionType @@ -51,12 +50,7 @@ public class ParticipantRobot( return this } - public fun sleep(timeOutMillis: Long = defaultTimeout): ParticipantRobot { - io.getstream.chat.android.compose.uiautomator.sleep(timeOutMillis) - return this - } - - public fun readMessage(parentId: String? = null): ParticipantRobot { + public fun readMessage(): ParticipantRobot { mockServer.postRequest("participant/read") return this } @@ -136,12 +130,12 @@ public class ParticipantRobot( return this } - public fun pinMesage(): ParticipantRobot { + public fun pinMessage(): ParticipantRobot { mockServer.postRequest("participant/message?action=pin") return this } - public fun unpinMesage(): ParticipantRobot { + public fun unpinMessage(): ParticipantRobot { mockServer.postRequest("participant/message?action=unpin") return this } 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 56c3cc21fd9..bedca8cd3a7 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 @@ -19,72 +19,123 @@ package io.getstream.chat.android.e2e.test.rules import android.database.sqlite.SQLiteDatabase import android.os.Environment import androidx.test.platform.app.InstrumentationRegistry -import io.getstream.chat.android.compose.uiautomator.allureLogcat -import io.getstream.chat.android.compose.uiautomator.allureScreenrecord -import io.getstream.chat.android.compose.uiautomator.allureScreenshot -import io.getstream.chat.android.compose.uiautomator.allureWindowHierarchy -import io.getstream.chat.android.compose.uiautomator.device +import io.getstream.chat.android.e2e.test.uiautomator.allureLogcat +import io.getstream.chat.android.e2e.test.uiautomator.allureScreenrecord +import io.getstream.chat.android.e2e.test.uiautomator.allureScreenshot +import io.getstream.chat.android.e2e.test.uiautomator.allureWindowHierarchy +import io.getstream.chat.android.e2e.test.uiautomator.device +import io.qameta.allure.kotlin.Allure +import io.qameta.allure.kotlin.model.Stage +import io.qameta.allure.kotlin.model.TestResult +import io.qameta.allure.kotlin.util.ResultsUtils import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement import java.io.File +import java.util.UUID -/** Annotation to retry a specific failed test. **/ -@Retention(AnnotationRetention.RUNTIME) -@Target(AnnotationTarget.FUNCTION) -public annotation class Retry(val count: Int) - -/** Rule to retry all failed tests. **/ +/** + * Rule to retry failed tests up to [count] attempts. + * + * Each failed attempt that is retried is written as its own Allure result sharing the real + * test's historyId, so Allure TestOps groups the attempts as retries and can flag the test + * as flaky. Screen recording only runs on retry attempts: the first attempt of a healthy + * test passes, and recording it would be paid for and discarded on every test. + */ public class RetryRule(private val count: Int) : TestRule { - override fun apply(base: Statement, description: Description): Statement = statement( - base, - description, - ) - - private fun statement(base: Statement, description: Description): Statement { - return object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - val testName = description.displayName - val retryAnnotation: Retry? = description.getAnnotation(Retry::class.java) - val retryCount = retryAnnotation?.count ?: count - val databaseOperations = DatabaseOperations() - var caughtThrowable: Throwable? = null - lateinit var videoFilePath: String - lateinit var recordingThread: Thread - - for (i in 0 until retryCount) { - try { - System.err.println("$testName: run #${i + 1} started.") - device.executeShellCommand("logcat -c") - videoFilePath = "${Environment.getExternalStorageDirectory().absolutePath}/$testName.mp4" + override fun apply(base: Statement, description: Description): Statement = object : Statement() { + @Throws(Throwable::class) + override fun evaluate() { + val testName = description.displayName + val databaseOperations = DatabaseOperations() + var caughtThrowable: Throwable? = null + + for (attempt in 1..count) { + val recordVideo = attempt > 1 + val videoFilePath = "${Environment.getExternalStorageDirectory().absolutePath}/$testName.mp4" + var recordingThread: Thread? = null + val startMillis = System.currentTimeMillis() + try { + System.err.println("$testName: run #$attempt started.") + device.executeShellCommand("logcat -c") + if (recordVideo) { recordingThread = startVideoRecording(videoFilePath) - base.evaluate() - stopVideoRecording(videoFilePath, recordingThread) - return - } catch (t: Throwable) { - System.err.println("$testName: run #${i + 1} failed.") - caughtThrowable = t - databaseOperations.clearDatabases() - stopVideoRecording(videoFilePath, recordingThread) - device.allureLogcat(name = "logcat_${i + 1}") - device.allureScreenshot(name = "screenshot_${i + 1}") - device.allureWindowHierarchy(name = "hierarchy_${i + 1}") - device.allureScreenrecord( - name = "record_${i + 1}", - file = File(videoFilePath), - ) - } finally { + } + base.evaluate() + recordingThread?.let { stopVideoRecording(videoFilePath, it) } + return + } catch (t: Throwable) { + System.err.println("$testName: run #$attempt failed.") + caughtThrowable = t + databaseOperations.clearDatabases() + recordingThread?.let { stopVideoRecording(videoFilePath, it) } + device.allureLogcat(name = "logcat_$attempt") + device.allureScreenshot(name = "screenshot_$attempt") + device.allureWindowHierarchy(name = "hierarchy_$attempt") + recordingThread?.let { + device.allureScreenrecord(name = "record_$attempt", file = File(videoFilePath)) + } + if (attempt < count) { + writeFailedAttemptResult(attempt, t, startMillis) + } + } finally { + if (recordingThread != null) { device.executeShellCommand("rm $videoFilePath") } } - - throw caughtThrowable ?: IllegalStateException() } + + throw caughtThrowable ?: IllegalStateException("$testName failed without a captured error") } } + /** + * Writes the failed attempt as a separate, already-finished Allure result. The current + * (real) result keeps running; its accumulated steps and attachments are moved to the + * attempt result so each result describes exactly one attempt. + */ + private fun writeFailedAttemptResult(attempt: Int, error: Throwable, startMillis: Long) { + val lifecycle = Allure.lifecycle + val attemptResult = TestResult(uuid = UUID.randomUUID().toString()) + var populated = false + var realStart: Long? = null + lifecycle.updateTestCase { current -> + attemptResult.historyId = current.historyId + attemptResult.testCaseId = current.testCaseId + attemptResult.fullName = current.fullName + attemptResult.name = current.name + attemptResult.description = current.description + attemptResult.labels.addAll(current.labels) + attemptResult.links.addAll(current.links) + attemptResult.parameters.addAll(current.parameters) + attemptResult.steps.addAll(current.steps) + attemptResult.attachments.addAll(current.attachments) + current.steps.clear() + current.attachments.clear() + realStart = current.start + populated = true + } + if (!populated) { + return + } + with(attemptResult) { + status = ResultsUtils.getStatus(error) + statusDetails = ResultsUtils.getStatusDetails(error) + // TestOps shows the same-historyId result with the latest start as the launch's + // current one and lists the rest as retries. The real result starts a few ms before + // any attempt, so each attempt's start is shifted just below it, keeping attempt + // order and leaving the real (final) result current. + start = realStart?.let { it - (count - attempt) } ?: startMillis + stop = System.currentTimeMillis() + } + // scheduleTestCase stores the result by reference and does not touch the thread context, + // so the running test stays current. It resets the stage, hence FINISHED is set after. + lifecycle.scheduleTestCase(attemptResult) + attemptResult.stage = Stage.FINISHED + lifecycle.writeTestCase(attemptResult.uuid) + } + private fun startVideoRecording(remoteVideoPath: String): Thread { return Thread { device.executeShellCommand( @@ -126,29 +177,24 @@ public class RetryRule(private val count: Int) : TestRule { } } -public open class DatabaseOperations { +private class DatabaseOperations { - public open fun clearDatabases() { - val databaseOperations = DatabaseOperations() - val dbFiles = databaseOperations.getAllDatabaseFiles().filterNot { - shouldIgnoreFile( - it.path, - ) - } - dbFiles.forEach { clearDatabase(it, databaseOperations) } + fun clearDatabases() { + getAllDatabaseFiles() + .filterNot(::shouldIgnoreFile) + .forEach(::clearDatabase) } - private fun shouldIgnoreFile(path: String): Boolean { + private fun shouldIgnoreFile(file: File): Boolean { val ignoredSuffixes = arrayOf("-journal", "-shm", "-uid", "-wal") - return ignoredSuffixes.any { path.endsWith(it) } + return ignoredSuffixes.any { file.path.endsWith(it) } } - private fun clearDatabase(dbFile: File, dbOperations: DatabaseOperations) { - dbOperations.openDatabase(dbFile).use { database -> - val tablesToClear = dbOperations.getTableNames( - database, - ).filterNot { it == "room_master_table" } - tablesToClear.forEach { dbOperations.deleteTableContent(database, it) } + private fun clearDatabase(dbFile: File) { + openDatabase(dbFile).use { database -> + getTableNames(database) + .filterNot { it == "room_master_table" } + .forEach { deleteTableContent(database, it) } } } 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 5f28ff1e942..ab525d98cbd 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 @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import android.annotation.SuppressLint import android.content.Context @@ -29,10 +29,6 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File -public fun UiDevice.stopApp() { - executeShellCommand("pm clear $packageName") -} - public fun UiDevice.typeText(text: String) { executeShellCommand("input text '$text'") } diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Base.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Base.kt index 18ec6f02b35..e4a0f2cb40c 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Base.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Base.kt @@ -14,11 +14,10 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import android.app.Instrumentation import android.content.Context -import android.content.res.AssetManager import android.content.res.Resources import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -43,11 +42,6 @@ public val appContext: Context get() = InstrumentationRegistry.getInstrumentatio */ public val testContext: Context get() = InstrumentationRegistry.getInstrumentation().context -/** - * AssetManager for androidTest environment. - */ -public val testAssets: AssetManager = testContext.assets - /** * Test runner instrumentation. */ @@ -62,13 +56,3 @@ public val resources: Resources get() = appContext.resources * Application package name. */ public val packageName: String get() = appContext.packageName.removeSuffix(".test") - -/** - * Get name of the resource by id. - */ -public infix fun Resources.nameOf(viewId: Int): String = getResourceName(viewId) - -/** - * Get string value of the resource by id. - */ -public infix fun Context.stringOf(stringResId: Int): String = getString(stringResId) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Element.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Element.kt index 4400804fe3a..c4d482b15b2 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Element.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Element.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import androidx.test.uiautomator.BySelector import androidx.test.uiautomator.UiObject2 diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/FindBy.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/FindBy.kt index 37c4e7eb09e..9dd7ddb8b8b 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/FindBy.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/FindBy.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import android.view.View import androidx.test.uiautomator.By diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Math.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Math.kt index 898347796b9..5c6605419b8 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Math.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Math.kt @@ -14,28 +14,10 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator -import android.graphics.Point -import android.graphics.Rect import androidx.test.uiautomator.UiObject2 -public fun Rect.bottomPoint(): Point { - val x = right - ((right - left) / 2) - val y = bottom - return Point(x, y) -} - -public fun Rect.leftPoint(): Point { - val x = left - val y = bottom - ((bottom - top) / 2) - return Point(x, y) -} - -public fun Long.toSeconds(): Int = (this / 1000).toInt() - public val Int.seconds: Long get() = (this * 1000).toLong() public val UiObject2.height: Int get() = visibleBounds.height() - -public val UiObject2.width: Int get() = visibleBounds.width() diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Permissions.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Permissions.kt index 0708eda6e9a..7ec0bc7185d 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Permissions.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Permissions.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import androidx.test.uiautomator.UiDevice diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/uiautomator/VisualOrder.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt similarity index 72% rename from stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/uiautomator/VisualOrder.kt rename to stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt index e589ad95426..9285a49c4ea 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/uiautomator/VisualOrder.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/VisualOrder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import androidx.test.uiautomator.BySelector import androidx.test.uiautomator.UiObject2 @@ -24,8 +24,10 @@ import androidx.test.uiautomator.UiObject2 * bottom-most first. Message cells are enumerated in screen-reader reading order * (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. */ -internal fun BySelector.waitToAppearBottomUp(timeOutMillis: Long = defaultTimeout): List { +public fun BySelector.waitToAppearBottomUp(timeOutMillis: Long = defaultTimeout): List { wait(timeOutMillis) return device.findObjects(this).sortedByDescending { it.visibleBounds.top } } @@ -33,8 +35,12 @@ internal fun BySelector.waitToAppearBottomUp(timeOutMillis: Long = defaultTimeou /** * Waits for objects matching this selector and returns the one at [withIndex], counting * bottom-up (index 0 = the bottom-most object on screen). + * + * @param withIndex The zero-based index counting from the bottom-most object. + * @param timeOutMillis Maximum time to wait before failing. + * @throws IllegalStateException when fewer than [withIndex] + 1 objects match. */ -internal fun BySelector.waitToAppearBottomUp(withIndex: Int, timeOutMillis: Long = defaultTimeout): UiObject2 { +public fun BySelector.waitToAppearBottomUp(withIndex: Int, timeOutMillis: Long = defaultTimeout): UiObject2 { val objects = waitToAppearBottomUp(timeOutMillis) return objects.getOrNull(withIndex) ?: error("waitToAppearBottomUp(withIndex=$withIndex): only ${objects.size} objects matched selector: $this") 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 eff46a82782..275abbc484b 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 @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.getstream.chat.android.compose.uiautomator +package io.getstream.chat.android.e2e.test.uiautomator import androidx.test.uiautomator.BySelector import androidx.test.uiautomator.StaleObjectException @@ -105,20 +105,10 @@ private fun BySelector.currentTextOrNull(): String? = try { public fun BySelector.waitForCount(count: Int, timeOutMillis: Long = defaultTimeout): List { val endTime = System.currentTimeMillis() + timeOutMillis - var elements: List = emptyList() - var success = false - while (!success && System.currentTimeMillis() < endTime) { + var elements: List = findObjects() + while (elements.size != count && System.currentTimeMillis() < endTime) { + Thread.sleep(POLL_INTERVAL_MILLIS) elements = findObjects() - success = elements.size == count } return elements } - -public fun UiObject2.waitForTextToChange(text: String, timeOutMillis: Long = defaultTimeout): UiObject2 { - val endTime = System.currentTimeMillis() + timeOutMillis - var textChanged = false - while (!textChanged && System.currentTimeMillis() < endTime) { - textChanged = this.text != text - } - return this -}