From 6c41232d7d759734f1725526f1469f812358f1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Jul 2026 11:39:51 +0100 Subject: [PATCH 1/4] e2e: Cover pinned messages, search, moderation, channel actions, threads and reminders Add 16 tests for the Compose sample screens the mock server already supports: the pinned messages screen, message search from the channel list header, flag, mute, unmute, block and unblock from the message menu, leave group, delete group and the swipe mute, the thread list, and the reminders screen. New page objects for the channel info, pinned messages, thread list and reminders screens. Their rows carry no test tags, so the assertions go through the visible text. The pinned row is matched by the text start, because the message preview formatter appends a trailing space to the message text. BackendRobot.createReminder seeds a reminder on the last message, since the sample has no way to create one. The bottom bar tabs get test tags, because the threads tab label and the thread list header title are both "Threads". Only Allure 5935 and 6071 are bound. 5934 needs channel search, which the sample does not wire, and 6070 and 6072 need a direct message channel, which the current mock fixture does not have. --- .../android/compose/pages/ChannelInfoPage.kt | 34 +++++ .../android/compose/pages/ChannelListPage.kt | 15 ++ .../android/compose/pages/MessageListPage.kt | 14 ++ .../compose/pages/PinnedMessagesPage.kt | 36 +++++ .../android/compose/pages/RemindersPage.kt | 37 +++++ .../android/compose/pages/ThreadListPage.kt | 42 ++++++ .../chat/android/compose/robots/UserRobot.kt | 104 ++++++++++++- .../robots/UserRobotChannelListAsserts.kt | 23 ++- .../robots/UserRobotMessageListAsserts.kt | 26 ++++ .../robots/UserRobotPinnedMessagesAsserts.kt | 39 +++++ .../robots/UserRobotRemindersAsserts.kt | 44 ++++++ .../robots/UserRobotThreadListAsserts.kt | 33 +++++ .../compose/tests/ChannelActionsTests.kt | 74 ++++++++++ .../android/compose/tests/ModerationTests.kt | 139 ++++++++++++++++++ .../compose/tests/PinnedMessagesTests.kt | 56 +++++++ .../android/compose/tests/RemindersTests.kt | 97 ++++++++++++ .../chat/android/compose/tests/SearchTests.kt | 77 ++++++++++ .../android/compose/tests/ThreadListTests.kt | 99 +++++++++++++ .../sample/ui/component/AppBottomBar.kt | 6 +- .../android/e2e/test/robots/BackendRobot.kt | 17 +++ 20 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelInfoPage.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/PinnedMessagesPage.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ThreadListPage.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPinnedMessagesAsserts.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotThreadListAsserts.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt create mode 100644 stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelInfoPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelInfoPage.kt new file mode 100644 index 00000000000..c264f8b68bc --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelInfoPage.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.pages + +import androidx.test.uiautomator.By +import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.ui.common.R as UiCommonR + +/** + * The channel info screen. Its rows carry no test tags, so they are matched by the option label. + */ +class ChannelInfoPage { + + companion object { + val groupTitle get() = By.text(appContext.getString(UiCommonR.string.stream_ui_channel_info_group_title)) + + val pinnedMessagesOption + get() = By.text(appContext.getString(UiCommonR.string.stream_ui_channel_info_option_pinned_messages)) + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt index 3a42983cecd..fd2ff36a551 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt @@ -19,6 +19,9 @@ package io.getstream.chat.android.compose.pages import androidx.test.uiautomator.By import androidx.test.uiautomator.BySelector import io.getstream.chat.android.compose.pages.MessageListPage.MessageList.Message +import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.compose.R as ComposeR +import io.getstream.chat.android.compose.sample.R as SampleR class ChannelListPage { @@ -31,6 +34,13 @@ class ChannelListPage { } } + class NavigationDrawer { + + companion object { + val reminders get() = By.text(appContext.getString(SampleR.string.navigation_drawer_later)) + } + } + class ChannelList { companion object { @@ -42,6 +52,7 @@ class ChannelListPage { companion object { val mute = By.desc("Mute") + val unmute = By.desc("Unmute") val more = By.desc("More") } } @@ -72,6 +83,10 @@ class ChannelListPage { val viewInfo = By.res("Stream_ContextMenu_View info") val leaveGroup = By.res("Stream_ContextMenu_Leave group") val deleteGroup = By.res("Stream_ContextMenu_Delete Group") + + // Leave and delete open a confirmation dialog whose confirm button is the generic + // dialog one, not the action label. + val confirmButton get() = By.text(appContext.getString(ComposeR.string.stream_compose_ok)) } } } 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 c03597f6a0b..78244a68306 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 @@ -19,7 +19,9 @@ package io.getstream.chat.android.compose.pages import androidx.test.uiautomator.By import androidx.test.uiautomator.BySelector import io.getstream.chat.android.e2e.test.mockserver.ReactionType +import io.getstream.chat.android.e2e.test.uiautomator.appContext import java.util.regex.Pattern +import io.getstream.chat.android.compose.R as ComposeR open class MessageListPage { @@ -83,6 +85,15 @@ open class MessageListPage { } } + class FlagMessageDialog { + + companion object { + // The dialog title repeats the message option label, so the body text is what + // identifies the dialog. + val body get() = By.text(appContext.getString(ComposeR.string.stream_compose_flag_message_text)) + } + } + class MessageList { companion object { @@ -160,7 +171,10 @@ open class MessageListPage { val flag get() = By.res("Stream_ContextMenu_Flag Message") val pin get() = By.res("Stream_ContextMenu_Pin to this Chat") val unpin get() = By.res("Stream_ContextMenu_Unpin from this Chat") + val muteUser get() = By.res("Stream_ContextMenu_Mute User") + val unmuteUser get() = By.res("Stream_ContextMenu_Unmute User") val block get() = By.res("Stream_ContextMenu_Block user") + val unblock get() = By.res("Stream_ContextMenu_Unblock user") val delete get() = By.res("Stream_ContextMenu_Delete Message") val showMoreReactions = By.desc("Show more reactions") val ok = By.text("OK") diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/PinnedMessagesPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/PinnedMessagesPage.kt new file mode 100644 index 00000000000..9283d292de5 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/PinnedMessagesPage.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.pages + +import androidx.test.uiautomator.By +import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.compose.R as ComposeR +import io.getstream.chat.android.compose.sample.R as SampleR + +/** + * The pinned messages screen. Its rows carry no test tags, so a pinned message is matched by the + * message text shown in the row preview. + */ +class PinnedMessagesPage { + + companion object { + val title get() = By.text(appContext.getString(SampleR.string.pinned_messages_title)) + + val emptyTitle + get() = By.text(appContext.getString(ComposeR.string.stream_compose_pinned_message_list_empty_title)) + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt new file mode 100644 index 00000000000..c0aefd62ca4 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.pages + +import androidx.test.uiautomator.By +import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.compose.sample.R as SampleR + +/** + * The message reminders screen, opened from the navigation drawer. Its rows carry no test tags, so + * a reminder is matched by the message text and the status label. + */ +class RemindersPage { + + companion object { + val title get() = By.text(appContext.getString(SampleR.string.reminders_title)) + + val emptyTitle get() = By.text(appContext.getString(SampleR.string.reminders_no_results)) + + val savedForLaterStatus + get() = By.text(appContext.getString(SampleR.string.reminders_status_save_for_later)) + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ThreadListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ThreadListPage.kt new file mode 100644 index 00000000000..876b08e1a1c --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ThreadListPage.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.pages + +import androidx.test.uiautomator.By +import io.getstream.chat.android.e2e.test.uiautomator.appContext +import io.getstream.chat.android.e2e.test.uiautomator.resources +import io.getstream.chat.android.compose.R as ComposeR + +/** + * The threads list screen, opened from the threads tab of the sample bottom bar. Its rows carry no + * test tags, so a thread is matched by the parent message preview and the reply count label. + */ +class ThreadListPage { + + companion object { + val threadsTab = By.res("Stream_BottomBarThreadsTab") + + val parentMessagePreview = By.res("Stream_MessagePreview") + + val emptyTitle + get() = By.text(appContext.getString(ComposeR.string.stream_compose_thread_list_empty_title)) + + fun repliesCountLabel(count: Int) = By.text( + resources.getQuantityString(ComposeR.plurals.stream_compose_thread_list_item_reply_count, count, count), + ) + } +} 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 7d03617ee15..c4fb99869d1 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.compose.robots import androidx.test.uiautomator.By +import io.getstream.chat.android.compose.pages.ChannelInfoPage import io.getstream.chat.android.compose.pages.ChannelListPage import io.getstream.chat.android.compose.pages.LoginPage import io.getstream.chat.android.compose.pages.MessageListPage @@ -25,6 +26,7 @@ import io.getstream.chat.android.compose.pages.MessageListPage.Composer 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.ThreadListPage import io.getstream.chat.android.compose.pages.ThreadPage import io.getstream.chat.android.e2e.test.mockserver.AttachmentType import io.getstream.chat.android.e2e.test.mockserver.ReactionType @@ -57,11 +59,18 @@ class UserRobot { return this } - fun logout(): UserRobot { + /** Opens the navigation drawer, which the channel list header avatar reveals. */ + fun openNavigationDrawer(): UserRobot { ChannelListPage.Header.userAvatar.waitToAppearAndClick() return this } + fun openReminders(): UserRobot { + openNavigationDrawer() + ChannelListPage.NavigationDrawer.reminders.waitToAppearAndClick() + return this + } + fun waitForChannelListToLoad(): UserRobot { ChannelListPage.ChannelList.channels.wait() return this @@ -192,6 +201,41 @@ class UserRobot { return this } + fun flagMessage(text: String): UserRobot { + openContextMenu(text) + ContextMenu.flag.waitToAppearAndClick() + return this + } + + fun confirmFlagMessage(): UserRobot { + ContextMenu.ok.waitToAppearAndClick() + return this + } + + fun muteMessageAuthor(text: String): UserRobot { + openContextMenu(text) + ContextMenu.muteUser.waitToAppearAndClick() + return this + } + + fun unmuteMessageAuthor(text: String): UserRobot { + openContextMenu(text) + ContextMenu.unmuteUser.waitToAppearAndClick() + return this + } + + fun blockMessageAuthor(text: String): UserRobot { + openContextMenu(text) + ContextMenu.block.waitToAppearAndClick() + return this + } + + fun unblockMessageAuthor(text: String): UserRobot { + openContextMenu(text) + ContextMenu.unblock.waitToAppearAndClick() + return this + } + fun pinMessage(messageCellIndex: Int = 0): UserRobot { openContextMenu(messageCellIndex) ContextMenu.pin.waitToAppearAndClick() @@ -381,11 +425,69 @@ class UserRobot { return this } + fun tapOnMuteSwipeAction(): UserRobot { + ChannelListPage.ChannelList.SwipeActions.mute.waitToAppearAndClick() + return this + } + + fun tapOnUnmuteSwipeAction(): UserRobot { + ChannelListPage.ChannelList.SwipeActions.unmute.waitToAppearAndClick() + return this + } + + fun tapOnLeaveGroup(): UserRobot { + ChannelListPage.ChannelMenu.leaveGroup.waitToAppearAndClick() + return this + } + + fun tapOnDeleteGroup(): UserRobot { + ChannelListPage.ChannelMenu.deleteGroup.waitToAppearAndClick() + return this + } + + fun confirmChannelAction(): UserRobot { + ChannelListPage.ChannelMenu.confirmButton.waitToAppearAndClick() + return this + } + fun tapOnViewChannelInfo(): UserRobot { ChannelListPage.ChannelMenu.viewInfo.waitToAppearAndClick() return this } + fun tapOnPinnedMessagesOption(): UserRobot { + ChannelInfoPage.pinnedMessagesOption.waitToAppearAndClick() + return this + } + + fun openThreadList(): UserRobot { + ThreadListPage.threadsTab.waitToAppearAndClick() + return this + } + + /** + * Taps the first thread of the thread list. The rows carry no test tag of their own, so the tap + * lands on the parent message preview inside the row, which the row handles. + */ + fun openThreadFromThreadList(): UserRobot { + ThreadListPage.parentMessagePreview.waitToAppearAndClick() + return this + } + + fun searchForMessage(text: String): UserRobot { + ChannelListPage.Header.searchField.waitToAppear().typeText(text) + return this + } + + /** + * Taps the first search result. The result rows carry no test tag of their own, so the tap + * lands on the message preview inside the row, which the row handles. + */ + fun tapOnSearchResult(): UserRobot { + ChannelListPage.ChannelList.Channel.messagePreview.waitToAppearAndClick() + return this + } + /** * Scrolls the message list up one page at a time until the message with [messageText] is * displayed and clear of the top edge of the list, giving up after [maxScrolls] pages. The 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 f47a79ced22..61f6368afec 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,6 +17,8 @@ package io.getstream.chat.android.compose.robots import androidx.test.uiautomator.By +import io.getstream.chat.android.compose.pages.ChannelInfoPage +import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelList import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelList.Channel import io.getstream.chat.android.compose.pages.ChannelListPage.ChannelMenu import io.getstream.chat.android.e2e.test.robots.ParticipantRobot @@ -29,7 +31,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import io.getstream.chat.android.compose.R as ComposeR -import io.getstream.chat.android.ui.common.R as UiCommonR fun UserRobot.assertChannelAvatar(): UserRobot { assertTrue(Channel.avatar.isDisplayed()) @@ -56,10 +57,8 @@ fun UserRobot.assertChannelActionsSheetForGroupChannel(): UserRobot { } fun UserRobot.assertGroupChannelInfoScreen(): UserRobot { - assertTrue(By.text(appContext.getString(UiCommonR.string.stream_ui_channel_info_group_title)).waitDisplayed()) - assertTrue( - By.text(appContext.getString(UiCommonR.string.stream_ui_channel_info_option_pinned_messages)).isDisplayed(), - ) + assertTrue(ChannelInfoPage.groupTitle.waitDisplayed()) + assertTrue(ChannelInfoPage.pinnedMessagesOption.isDisplayed()) return this } @@ -81,6 +80,20 @@ fun UserRobot.assertFailedMessageDeliveryStatusInPreview(): UserRobot { return this } +fun UserRobot.assertChannelIsMuted(isMuted: Boolean): UserRobot { + if (isMuted) { + assertTrue(Channel.mutedIcon.waitDisplayed()) + } else { + assertFalse(Channel.mutedIcon.waitToDisappear().isDisplayed()) + } + return this +} + +fun UserRobot.assertChannelListIsEmpty(): UserRobot { + assertFalse(ChannelList.channels.waitToDisappear().isDisplayed()) + return this +} + fun UserRobot.assertMessagePreviewTimestamp(isDisplayed: Boolean = true): UserRobot { if (isDisplayed) { assertTrue(Channel.timestamp.waitDisplayed()) 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 b34d3f44a94..27898adc258 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 @@ -24,6 +24,7 @@ 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.MessageListPage.MessageList.Message.ContextMenu import io.getstream.chat.android.compose.pages.ThreadPage import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.getstream.chat.android.e2e.test.mockserver.ReactionType @@ -542,3 +543,28 @@ fun UserRobot.assertLinkPreviewInComposer(isDisplayed: Boolean): UserRobot { } return this } + +fun UserRobot.assertFlagMessageDialog(isDisplayed: Boolean): UserRobot { + assertVisibility(MessageListPage.FlagMessageDialog.body, isDisplayed) + return this +} + +/** + * Asserts the mute option the message menu offers for the message author. The sample has no muted + * users list, so the option label is the only place the mute state is visible. + */ +fun UserRobot.assertMuteMessageAuthorOption(isAuthorMuted: Boolean): UserRobot { + val expectedOption = if (isAuthorMuted) ContextMenu.unmuteUser else ContextMenu.muteUser + assertTrue(expectedOption.waitDisplayed()) + return this +} + +/** + * Asserts the block option the message menu offers for the message author. The sample has no + * blocked users list, so the option label is the only place the block state is visible. + */ +fun UserRobot.assertBlockMessageAuthorOption(isAuthorBlocked: Boolean): UserRobot { + val expectedOption = if (isAuthorBlocked) ContextMenu.unblock else ContextMenu.block + assertTrue(expectedOption.waitDisplayed()) + return this +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPinnedMessagesAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPinnedMessagesAsserts.kt new file mode 100644 index 00000000000..385a04e00c7 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotPinnedMessagesAsserts.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.robots + +import androidx.test.uiautomator.By +import io.getstream.chat.android.compose.pages.PinnedMessagesPage +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed +import org.junit.Assert.assertTrue + +fun UserRobot.assertPinnedMessagesScreen(): UserRobot { + assertTrue(PinnedMessagesPage.title.waitDisplayed()) + return this +} + +fun UserRobot.assertMessageInPinnedMessages(text: String): UserRobot { + // The row renders the message text through the preview formatter, which appends a trailing + // space to it, so the text is matched by its start. + assertTrue(By.textStartsWith(text).waitDisplayed()) + return this +} + +fun UserRobot.assertPinnedMessagesAreEmpty(): UserRobot { + assertTrue(PinnedMessagesPage.emptyTitle.waitDisplayed()) + return this +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt new file mode 100644 index 00000000000..02b822acb26 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.robots + +import androidx.test.uiautomator.By +import io.getstream.chat.android.compose.pages.RemindersPage +import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed +import org.junit.Assert.assertTrue + +fun UserRobot.assertRemindersScreen(): UserRobot { + assertTrue(RemindersPage.title.waitDisplayed()) + return this +} + +fun UserRobot.assertReminder(messageText: String): UserRobot { + assertTrue(By.text(messageText).waitDisplayed()) + return this +} + +fun UserRobot.assertReminderSavedForLater(messageText: String): UserRobot { + assertReminder(messageText) + assertTrue(RemindersPage.savedForLaterStatus.isDisplayed()) + return this +} + +fun UserRobot.assertRemindersAreEmpty(): UserRobot { + assertTrue(RemindersPage.emptyTitle.waitDisplayed()) + return this +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotThreadListAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotThreadListAsserts.kt new file mode 100644 index 00000000000..03c6423e999 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotThreadListAsserts.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.robots + +import io.getstream.chat.android.compose.pages.ThreadListPage +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed +import org.junit.Assert.assertTrue + +fun UserRobot.assertThreadInThreadList(parentMessageText: String, replies: Int): UserRobot { + // The preview is prefixed with the sender name, so it is matched as a substring. + assertTrue(ThreadListPage.parentMessagePreview.textContains(parentMessageText).waitDisplayed()) + assertTrue(ThreadListPage.repliesCountLabel(replies).waitDisplayed()) + return this +} + +fun UserRobot.assertThreadListIsEmpty(): UserRobot { + assertTrue(ThreadListPage.emptyTitle.waitDisplayed()) + return this +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt index 153f1ce6e81..a0aa39ec9e9 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertChannelActionsSheetForGroupChannel +import io.getstream.chat.android.compose.robots.assertChannelIsMuted +import io.getstream.chat.android.compose.robots.assertChannelListIsEmpty import io.getstream.chat.android.compose.robots.assertGroupChannelInfoScreen import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.qameta.allure.kotlin.Allure.step @@ -71,4 +73,76 @@ class ChannelActionsTests : StreamTestCase() { userRobot.assertGroupChannelInfoScreen() } } + + @Test + fun test_userLeavesGroupChannel() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("AND user long presses the channel") { + userRobot.openChannelMenu() + } + step("WHEN user leaves the group") { + userRobot + .tapOnLeaveGroup() + .confirmChannelAction() + } + step("THEN the channel is gone from the channel list") { + userRobot.assertChannelListIsEmpty() + } + } + + @Test + fun test_userDeletesGroupChannel() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("AND user long presses the channel") { + userRobot.openChannelMenu() + } + step("WHEN user deletes the group") { + userRobot + .tapOnDeleteGroup() + .confirmChannelAction() + } + step("THEN the channel is gone from the channel list") { + userRobot.assertChannelListIsEmpty() + } + } + + @Test + fun test_userMutesChannelFromTheSwipeAction() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("WHEN user swipes the channel and taps on the mute action") { + userRobot + .swipeChannel() + .tapOnMuteSwipeAction() + } + step("THEN the channel shows the muted icon") { + userRobot.assertChannelIsMuted(isMuted = true) + } + } + + @Test + fun test_userUnmutesChannelFromTheSwipeAction() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("AND user mutes the channel from the swipe action") { + userRobot + .swipeChannel() + .tapOnMuteSwipeAction() + .assertChannelIsMuted(isMuted = true) + } + step("WHEN user swipes the channel and taps on the unmute action") { + userRobot + .swipeChannel() + .tapOnUnmuteSwipeAction() + } + step("THEN the channel shows no muted icon") { + userRobot.assertChannelIsMuted(isMuted = false) + } + } } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt new file mode 100644 index 00000000000..92f5e1ad2ed --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.tests + +import io.getstream.chat.android.compose.robots.assertBlockMessageAuthorOption +import io.getstream.chat.android.compose.robots.assertFlagMessageDialog +import io.getstream.chat.android.compose.robots.assertMessage +import io.getstream.chat.android.compose.robots.assertMuteMessageAuthorOption +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import org.junit.Test + +/** + * Covers the moderation options the message menu offers for another user's message: flagging the + * message, and muting or blocking its author. The options are absent on the user's own messages. + */ +class ModerationTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + private val sampleText = "Test" + + @Test + fun test_userFlagsMessage() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("WHEN user taps on the flag option of the message") { + userRobot.flagMessage(sampleText) + } + step("THEN the flag confirmation is shown") { + userRobot.assertFlagMessageDialog(isDisplayed = true) + } + step("WHEN user confirms flagging the message") { + userRobot.confirmFlagMessage() + } + step("THEN the confirmation is dismissed and the message stays in the message list") { + userRobot + .assertFlagMessageDialog(isDisplayed = false) + .assertMessage(sampleText) + } + } + + @Test + fun test_userMutesMessageAuthor() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("WHEN user mutes the message author") { + userRobot.muteMessageAuthor(sampleText) + } + step("THEN the message menu offers to unmute the author") { + userRobot + .openContextMenu(sampleText) + .assertMuteMessageAuthorOption(isAuthorMuted = true) + } + } + + @Test + fun test_userUnmutesMessageAuthor() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND user mutes the message author") { + userRobot.muteMessageAuthor(sampleText) + } + step("WHEN user unmutes the message author") { + userRobot.unmuteMessageAuthor(sampleText) + } + step("THEN the message menu offers to mute the author again") { + userRobot + .openContextMenu(sampleText) + .assertMuteMessageAuthorOption(isAuthorMuted = false) + } + } + + @AllureId("6071") + @Test + fun test_userBlocksMessageAuthor() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("WHEN user blocks the message author") { + userRobot.blockMessageAuthor(sampleText) + } + step("THEN the message menu offers to unblock the author") { + userRobot + .openContextMenu(sampleText) + .assertBlockMessageAuthorOption(isAuthorBlocked = true) + } + } + + @Test + fun test_userUnblocksMessageAuthor() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND user blocks the message author") { + userRobot.blockMessageAuthor(sampleText) + } + step("WHEN user unblocks the message author") { + userRobot.unblockMessageAuthor(sampleText) + } + step("THEN the message menu offers to block the author again") { + userRobot + .openContextMenu(sampleText) + .assertBlockMessageAuthorOption(isAuthorBlocked = false) + } + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt index 24b2b706ae0..9d3046abd2f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt @@ -17,7 +17,10 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertMessageDeliveryStatus +import io.getstream.chat.android.compose.robots.assertMessageInPinnedMessages import io.getstream.chat.android.compose.robots.assertMessagePinnedLabel +import io.getstream.chat.android.compose.robots.assertPinnedMessagesAreEmpty +import io.getstream.chat.android.compose.robots.assertPinnedMessagesScreen import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.getstream.chat.android.e2e.test.robots.ParticipantRobot @@ -87,6 +90,59 @@ class PinnedMessagesTests : StreamTestCase() { } } + @Test + fun test_pinnedMessageIsShownOnThePinnedMessagesScreen() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND user pins the message") { + userRobot.pinMessage() + } + step("WHEN user opens the pinned messages screen") { + userRobot + .moveToChannelListFromMessageList() + .openChannelMenu() + .tapOnViewChannelInfo() + .tapOnPinnedMessagesOption() + } + step("THEN the pinned message is shown") { + userRobot + .assertPinnedMessagesScreen() + .assertMessageInPinnedMessages(sampleText) + } + } + + @Test + fun test_unpinnedMessageIsNotShownOnThePinnedMessagesScreen() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND user pins the message") { + userRobot.pinMessage() + } + step("WHEN user unpins the message") { + userRobot.unpinMessage() + } + step("AND user opens the pinned messages screen") { + userRobot + .moveToChannelListFromMessageList() + .openChannelMenu() + .tapOnViewChannelInfo() + .tapOnPinnedMessagesOption() + } + step("THEN no pinned message is shown") { + userRobot + .assertPinnedMessagesScreen() + .assertPinnedMessagesAreEmpty() + } + } + @AllureId("11385") @Test fun test_participantUnpinsMessage() { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt new file mode 100644 index 00000000000..2eb356a4e1d --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.tests + +import io.getstream.chat.android.compose.robots.assertReminder +import io.getstream.chat.android.compose.robots.assertReminderSavedForLater +import io.getstream.chat.android.compose.robots.assertRemindersAreEmpty +import io.getstream.chat.android.compose.robots.assertRemindersScreen +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.qameta.allure.kotlin.Allure.step +import org.junit.Test + +/** + * Covers the message reminders screen, which the sample opens from the navigation drawer. The + * sample cannot create a reminder, so the reminders are seeded on the server side. + */ +class RemindersTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + private val sampleText = "Test" + private val oneHourInSeconds = 3600 + + @Test + fun test_remindersScreenIsEmpty_whenUserHasNoReminders() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("WHEN user opens the reminders screen") { + userRobot.openReminders() + } + step("THEN no reminder is shown") { + userRobot + .assertRemindersScreen() + .assertRemindersAreEmpty() + } + } + + @Test + fun test_reminderSavedForLaterIsShownOnTheRemindersScreen() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND the message is saved for later") { + backendRobot.createReminder() + } + step("WHEN user opens the reminders screen") { + userRobot + .moveToChannelListFromMessageList() + .openReminders() + } + step("THEN the message is shown as saved for later") { + userRobot + .assertRemindersScreen() + .assertReminderSavedForLater(sampleText) + } + } + + @Test + fun test_scheduledReminderIsShownOnTheRemindersScreen() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND a reminder for the message is due in one hour") { + backendRobot.createReminder(remindAtSeconds = oneHourInSeconds) + } + step("WHEN user opens the reminders screen") { + userRobot + .moveToChannelListFromMessageList() + .openReminders() + } + step("THEN the message is shown on the reminders screen") { + userRobot + .assertRemindersScreen() + .assertReminder(sampleText) + } + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt new file mode 100644 index 00000000000..7bf39076c4e --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.tests + +import io.getstream.chat.android.compose.robots.assertMessage +import io.getstream.chat.android.compose.robots.assertMessageInChannelPreview +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import org.junit.Test + +/** + * Covers the message search wired into the channel list header. The sample configures the channel + * list with `SearchMode.Messages` only, so channel search is not reachable. + */ +class SearchTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + private val sampleText = "Test" + + @AllureId("5935") + @Test + fun test_userSearchesForMessage() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("WHEN user searches for the message on the channel list") { + userRobot + .moveToChannelListFromMessageList() + .searchForMessage(sampleText) + } + step("THEN the message is shown in the search results") { + userRobot.assertMessageInChannelPreview(sampleText, fromCurrentUser = false) + } + } + + @Test + fun test_userOpensMessageFromSearchResults() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(sampleText) + } + step("AND user searches for the message on the channel list") { + userRobot + .moveToChannelListFromMessageList() + .searchForMessage(sampleText) + .assertMessageInChannelPreview(sampleText, fromCurrentUser = false) + } + step("WHEN user taps on the search result") { + userRobot.tapOnSearchResult() + } + step("THEN the message list is opened on the message") { + userRobot + .waitForMessageListToLoad() + .assertMessage(sampleText) + } + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt new file mode 100644 index 00000000000..8bcb7b8a072 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.tests + +import io.getstream.chat.android.compose.robots.assertThreadInThreadList +import io.getstream.chat.android.compose.robots.assertThreadListIsEmpty +import io.getstream.chat.android.compose.robots.assertThreadMessage +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.qameta.allure.kotlin.Allure.step +import org.junit.Test + +/** + * Covers the thread list screen, which the sample opens from the threads tab of the bottom bar. + */ +class ThreadListTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + private val parentMessageText = "Test" + private val replyText = "Reply" + + @Test + fun test_threadListIsEmpty_whenChannelHasNoThreads() { + step("GIVEN user logs in") { + userRobot.login().waitForChannelListToLoad() + } + step("WHEN user opens the thread list") { + userRobot.openThreadList() + } + step("THEN the thread list is empty") { + userRobot.assertThreadListIsEmpty() + } + } + + @Test + fun test_threadIsShownOnTheThreadList() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(parentMessageText) + } + step("AND user replies to the message in the thread") { + userRobot + .openThread() + .sendMessageInThread(replyText) + .assertThreadMessage(replyText) + } + step("WHEN user opens the thread list") { + userRobot + .moveToChannelListFromThread() + .openThreadList() + } + step("THEN the thread is shown with one reply") { + userRobot.assertThreadInThreadList(parentMessageText, replies = 1) + } + } + + @Test + fun test_userOpensThreadFromTheThreadList() { + step("GIVEN user opens the channel") { + userRobot.login().openChannel() + } + step("AND participant sends the message") { + participantRobot.sendMessage(parentMessageText) + } + step("AND user replies to the message in the thread") { + userRobot + .openThread() + .sendMessageInThread(replyText) + .assertThreadMessage(replyText) + } + step("AND user opens the thread list") { + userRobot + .moveToChannelListFromThread() + .openThreadList() + .assertThreadInThreadList(parentMessageText, replies = 1) + } + step("WHEN user taps on the thread") { + userRobot.openThreadFromThreadList() + } + step("THEN the thread is opened on the reply") { + userRobot.assertThreadMessage(replyText) + } + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt index 0c37391ba93..f07bdac06d0 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/AppBottomBar.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -79,6 +80,7 @@ fun AppBottomBar( selectedIcon = ComposeR.drawable.stream_design_ic_message_bubble_fill, unselectedIcon = ComposeR.drawable.stream_design_ic_message_bubble, text = R.string.app_bottom_bar_chats, + modifier = Modifier.testTag("Stream_BottomBarChatsTab"), isSelected = selectedOption == AppBottomBarOption.CHATS, onClick = { onOptionSelected(AppBottomBarOption.CHATS) }, decorationBadge = if (unreadChannelsCount > 0) { @@ -91,6 +93,7 @@ fun AppBottomBar( selectedIcon = ComposeR.drawable.stream_design_ic_thread_fill, unselectedIcon = ComposeR.drawable.stream_design_ic_thread, text = R.string.app_bottom_bar_threads, + modifier = Modifier.testTag("Stream_BottomBarThreadsTab"), isSelected = selectedOption == AppBottomBarOption.THREADS, onClick = { onOptionSelected(AppBottomBarOption.THREADS) }, decorationBadge = if (unreadThreadsCount > 0) { @@ -118,13 +121,14 @@ private fun AppBottomBarOptionTile( @StringRes text: Int, isSelected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, decorationBadge: (@Composable () -> Unit)? = null, ) { val contentColor by animateColorAsState( targetValue = if (isSelected) ChatTheme.colors.textPrimary else ChatTheme.colors.textTertiary, ) Column( - modifier = Modifier + modifier = modifier .clip(CircleShape) .clickable( indication = ripple(), diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/BackendRobot.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/BackendRobot.kt index 36aa2431f2f..df5cad2d1da 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/BackendRobot.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/robots/BackendRobot.kt @@ -107,6 +107,23 @@ public class BackendRobot( return this } + /** + * Creates a reminder for the last message of the currently open channel on the server side. + * The app under test has no way to create a reminder, so tests seed it here. + * + * @param remindAtSeconds How far in the future the reminder is due, in seconds. Pass `null` + * for a reminder that is saved for later, which has no due date. + */ + public fun createReminder(remindAtSeconds: Int? = null): BackendRobot { + val endpoint = if (remindAtSeconds == null) { + "create_reminder" + } else { + "create_reminder?remind_at=$remindAtSeconds" + } + mockServer.postRequest(endpoint) + return this + } + public fun revokeToken(duration: Int = 5) { waitForMockServerToStart() mockServer.postRequest("jwt/revoke_token?duration=$duration") From 55ecc05482b5b542d9463f760b50b70fc6f77a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Jul 2026 13:26:06 +0100 Subject: [PATCH 2/4] e2e: Scope the saved for later assertion to the reminder row The status was matched anywhere on the screen, so another reminder's status could satisfy the assertion. The reminder row now carries a test tag, and both the reminder and its status are looked up inside the row of the asserted message. The reminders screen uses SampleChatTheme, because test tags are only exposed as resource ids under that wrapper. --- .../chat/android/compose/pages/RemindersPage.kt | 4 ++++ .../compose/robots/UserRobotRemindersAsserts.kt | 12 +++++++----- .../feature/reminders/MessageRemindersActivity.kt | 4 ++-- .../feature/reminders/MessageRemindersScreen.kt | 2 ++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt index c0aefd62ca4..a6bb59d40b7 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt @@ -33,5 +33,9 @@ class RemindersPage { val savedForLaterStatus get() = By.text(appContext.getString(SampleR.string.reminders_status_save_for_later)) + + /** The reminder row of the message with [messageText]. */ + fun reminder(messageText: String) = + By.res("Stream_MessageReminderItem").hasDescendant(By.text(messageText)) } } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt index 02b822acb26..7b40a1e9acc 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt @@ -16,10 +16,10 @@ package io.getstream.chat.android.compose.robots -import androidx.test.uiautomator.By import io.getstream.chat.android.compose.pages.RemindersPage -import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed +import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue fun UserRobot.assertRemindersScreen(): UserRobot { @@ -28,13 +28,15 @@ fun UserRobot.assertRemindersScreen(): UserRobot { } fun UserRobot.assertReminder(messageText: String): UserRobot { - assertTrue(By.text(messageText).waitDisplayed()) + assertTrue(RemindersPage.reminder(messageText).waitDisplayed()) return this } fun UserRobot.assertReminderSavedForLater(messageText: String): UserRobot { - assertReminder(messageText) - assertTrue(RemindersPage.savedForLaterStatus.isDisplayed()) + // The status is looked up inside the row of this message, so another reminder's status + // cannot satisfy the assertion. + val reminder = RemindersPage.reminder(messageText).waitToAppear() + assertNotNull(reminder.findObject(RemindersPage.savedForLaterStatus)) return this } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersActivity.kt index 8dfe52286be..50b60d5bd4e 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersActivity.kt @@ -21,8 +21,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.ui.Modifier +import io.getstream.chat.android.compose.sample.ui.SampleChatTheme import io.getstream.chat.android.compose.sample.ui.channel.ChannelActivity -import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.models.Message /** @@ -33,7 +33,7 @@ class MessageRemindersActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - ChatTheme { + SampleChatTheme { MessageRemindersScreen( modifier = Modifier.statusBarsPadding(), onReminderClick = { reminder -> diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersScreen.kt index 5a806485e55..646d3a3dff0 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersScreen.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/reminders/MessageRemindersScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -315,6 +316,7 @@ private fun MessageReminderItem( Column( modifier = modifier .fillMaxWidth() + .testTag("Stream_MessageReminderItem") .background(ChatTheme.colors.backgroundCoreApp) .padding(12.dp), ) { From fd0c90e18c82037c5ad25b2a3fdc5855c845df85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Jul 2026 14:24:40 +0100 Subject: [PATCH 3/4] e2e: Reopen the message menu until the moderation option flips The message menu builds its options when it opens and keeps them while it stays open, so the option label only reflects a mute or a block that landed before the open. On the CI emulator the mute response took 527ms, which is longer than the tap and the reopen, so the reopened menu was built from the old state and the assertion waited on a label that never changed. It made test_userMutesMessageAuthor and test_userBlocksMessageAuthor fail on API 36 while they passed locally, where the same request takes a few milliseconds. The moderation assertions now open the menu themselves and reopen it while the expected option is missing, and the unmute and unblock actions do the same before they tap. Bind the QA cases that TestOps created for the tests of the previous commit. --- .../chat/android/compose/robots/UserRobot.kt | 26 +++++++++++++++++-- .../robots/UserRobotMessageListAsserts.kt | 20 ++++++++------ .../compose/tests/ChannelActionsTests.kt | 4 +++ .../android/compose/tests/ModerationTests.kt | 20 ++++++-------- .../compose/tests/PinnedMessagesTests.kt | 2 ++ .../android/compose/tests/RemindersTests.kt | 4 +++ .../chat/android/compose/tests/SearchTests.kt | 1 + .../android/compose/tests/ThreadListTests.kt | 4 +++ 8 files changed, 59 insertions(+), 22 deletions(-) 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 c4fb99869d1..8586c838086 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.compose.robots import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector import io.getstream.chat.android.compose.pages.ChannelInfoPage import io.getstream.chat.android.compose.pages.ChannelListPage import io.getstream.chat.android.compose.pages.LoginPage @@ -36,6 +37,7 @@ import io.getstream.chat.android.e2e.test.uiautomator.device 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.seconds import io.getstream.chat.android.e2e.test.uiautomator.sleep import io.getstream.chat.android.e2e.test.uiautomator.swipeDown import io.getstream.chat.android.e2e.test.uiautomator.swipeUp @@ -201,6 +203,24 @@ class UserRobot { return this } + /** + * Opens the message menu of the message with [text], reopening it while [option] is missing. + * The menu builds its options when it opens and keeps them while it stays open, so an option + * that flips because of a moderation action shows up only on a later open. + */ + internal fun openContextMenuWithOption(text: String, option: BySelector): UserRobot { + repeat(contextMenuOpenAttempts) { attempt -> + openContextMenu(text) + if (option.waitDisplayed(timeOutMillis = 5.seconds)) { + return this + } + if (attempt < contextMenuOpenAttempts - 1) { + pressBack() + } + } + return this + } + fun flagMessage(text: String): UserRobot { openContextMenu(text) ContextMenu.flag.waitToAppearAndClick() @@ -219,7 +239,7 @@ class UserRobot { } fun unmuteMessageAuthor(text: String): UserRobot { - openContextMenu(text) + openContextMenuWithOption(text, ContextMenu.unmuteUser) ContextMenu.unmuteUser.waitToAppearAndClick() return this } @@ -231,7 +251,7 @@ class UserRobot { } fun unblockMessageAuthor(text: String): UserRobot { - openContextMenu(text) + openContextMenuWithOption(text, ContextMenu.unblock) ContextMenu.unblock.waitToAppearAndClick() return this } @@ -632,3 +652,5 @@ class UserRobot { return this } } + +private const val contextMenuOpenAttempts = 3 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 27898adc258..9b11eee35cf 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 @@ -550,21 +550,25 @@ fun UserRobot.assertFlagMessageDialog(isDisplayed: Boolean): UserRobot { } /** - * Asserts the mute option the message menu offers for the message author. The sample has no muted - * users list, so the option label is the only place the mute state is visible. + * Opens the message menu of the message with [messageText] and asserts the mute option it offers + * for the author. The sample has no muted users list, so the option label is the only place the + * mute state is visible. */ -fun UserRobot.assertMuteMessageAuthorOption(isAuthorMuted: Boolean): UserRobot { +fun UserRobot.assertMuteMessageAuthorOption(messageText: String, isAuthorMuted: Boolean): UserRobot { val expectedOption = if (isAuthorMuted) ContextMenu.unmuteUser else ContextMenu.muteUser - assertTrue(expectedOption.waitDisplayed()) + openContextMenuWithOption(messageText, expectedOption) + assertTrue(expectedOption.isDisplayed()) return this } /** - * Asserts the block option the message menu offers for the message author. The sample has no - * blocked users list, so the option label is the only place the block state is visible. + * Opens the message menu of the message with [messageText] and asserts the block option it offers + * for the author. The sample has no blocked users list, so the option label is the only place the + * block state is visible. */ -fun UserRobot.assertBlockMessageAuthorOption(isAuthorBlocked: Boolean): UserRobot { +fun UserRobot.assertBlockMessageAuthorOption(messageText: String, isAuthorBlocked: Boolean): UserRobot { val expectedOption = if (isAuthorBlocked) ContextMenu.unblock else ContextMenu.block - assertTrue(expectedOption.waitDisplayed()) + openContextMenuWithOption(messageText, expectedOption) + assertTrue(expectedOption.isDisplayed()) return this } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt index a0aa39ec9e9..29c6426f4c0 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt @@ -74,6 +74,7 @@ class ChannelActionsTests : StreamTestCase() { } } + @AllureId("11569") @Test fun test_userLeavesGroupChannel() { step("GIVEN user logs in") { @@ -92,6 +93,7 @@ class ChannelActionsTests : StreamTestCase() { } } + @AllureId("11573") @Test fun test_userDeletesGroupChannel() { step("GIVEN user logs in") { @@ -110,6 +112,7 @@ class ChannelActionsTests : StreamTestCase() { } } + @AllureId("11560") @Test fun test_userMutesChannelFromTheSwipeAction() { step("GIVEN user logs in") { @@ -125,6 +128,7 @@ class ChannelActionsTests : StreamTestCase() { } } + @AllureId("11567") @Test fun test_userUnmutesChannelFromTheSwipeAction() { step("GIVEN user logs in") { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt index 92f5e1ad2ed..30a9b5b4ceb 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt @@ -34,6 +34,7 @@ class ModerationTests : StreamTestCase() { override fun initTestActivity() = InitTestActivity.UserLogin private val sampleText = "Test" + @AllureId("11572") @Test fun test_userFlagsMessage() { step("GIVEN user opens the channel") { @@ -58,6 +59,7 @@ class ModerationTests : StreamTestCase() { } } + @AllureId("11563") @Test fun test_userMutesMessageAuthor() { step("GIVEN user opens the channel") { @@ -70,12 +72,11 @@ class ModerationTests : StreamTestCase() { userRobot.muteMessageAuthor(sampleText) } step("THEN the message menu offers to unmute the author") { - userRobot - .openContextMenu(sampleText) - .assertMuteMessageAuthorOption(isAuthorMuted = true) + userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted = true) } } + @AllureId("11566") @Test fun test_userUnmutesMessageAuthor() { step("GIVEN user opens the channel") { @@ -91,9 +92,7 @@ class ModerationTests : StreamTestCase() { userRobot.unmuteMessageAuthor(sampleText) } step("THEN the message menu offers to mute the author again") { - userRobot - .openContextMenu(sampleText) - .assertMuteMessageAuthorOption(isAuthorMuted = false) + userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted = false) } } @@ -110,12 +109,11 @@ class ModerationTests : StreamTestCase() { userRobot.blockMessageAuthor(sampleText) } step("THEN the message menu offers to unblock the author") { - userRobot - .openContextMenu(sampleText) - .assertBlockMessageAuthorOption(isAuthorBlocked = true) + userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked = true) } } + @AllureId("11575") @Test fun test_userUnblocksMessageAuthor() { step("GIVEN user opens the channel") { @@ -131,9 +129,7 @@ class ModerationTests : StreamTestCase() { userRobot.unblockMessageAuthor(sampleText) } step("THEN the message menu offers to block the author again") { - userRobot - .openContextMenu(sampleText) - .assertBlockMessageAuthorOption(isAuthorBlocked = false) + userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked = false) } } } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt index 9d3046abd2f..100c21147a5 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/PinnedMessagesTests.kt @@ -90,6 +90,7 @@ class PinnedMessagesTests : StreamTestCase() { } } + @AllureId("11568") @Test fun test_pinnedMessageIsShownOnThePinnedMessagesScreen() { step("GIVEN user opens the channel") { @@ -115,6 +116,7 @@ class PinnedMessagesTests : StreamTestCase() { } } + @AllureId("11565") @Test fun test_unpinnedMessageIsNotShownOnThePinnedMessagesScreen() { step("GIVEN user opens the channel") { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt index 2eb356a4e1d..7b799905256 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.compose.robots.assertRemindersAreEmpty import io.getstream.chat.android.compose.robots.assertRemindersScreen import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId import org.junit.Test /** @@ -34,6 +35,7 @@ class RemindersTests : StreamTestCase() { private val sampleText = "Test" private val oneHourInSeconds = 3600 + @AllureId("11574") @Test fun test_remindersScreenIsEmpty_whenUserHasNoReminders() { step("GIVEN user logs in") { @@ -49,6 +51,7 @@ class RemindersTests : StreamTestCase() { } } + @AllureId("11562") @Test fun test_reminderSavedForLaterIsShownOnTheRemindersScreen() { step("GIVEN user opens the channel") { @@ -72,6 +75,7 @@ class RemindersTests : StreamTestCase() { } } + @AllureId("11571") @Test fun test_scheduledReminderIsShownOnTheRemindersScreen() { step("GIVEN user opens the channel") { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt index 7bf39076c4e..15b7dfb7a3f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt @@ -51,6 +51,7 @@ class SearchTests : StreamTestCase() { } } + @AllureId("11561") @Test fun test_userOpensMessageFromSearchResults() { step("GIVEN user opens the channel") { diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt index 8bcb7b8a072..95f3af4db35 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.compose.robots.assertThreadListIsEmpty import io.getstream.chat.android.compose.robots.assertThreadMessage import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId import org.junit.Test /** @@ -32,6 +33,7 @@ class ThreadListTests : StreamTestCase() { private val parentMessageText = "Test" private val replyText = "Reply" + @AllureId("11564") @Test fun test_threadListIsEmpty_whenChannelHasNoThreads() { step("GIVEN user logs in") { @@ -45,6 +47,7 @@ class ThreadListTests : StreamTestCase() { } } + @AllureId("11570") @Test fun test_threadIsShownOnTheThreadList() { step("GIVEN user opens the channel") { @@ -69,6 +72,7 @@ class ThreadListTests : StreamTestCase() { } } + @AllureId("11576") @Test fun test_userOpensThreadFromTheThreadList() { step("GIVEN user opens the channel") { From 12bbb969a12280549f79f357c627a2022d715b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Jul 2026 15:48:35 +0100 Subject: [PATCH 4/4] e2e: Descope the mute and block state tests The mock server rebuilds no state into its health check: it pushes a payload made once at startup, with an empty mutes list, an empty channel mutes list and no blocked users, every two seconds. The client applies every own-user event as a full current-user update, so a mute or a block disappears about two seconds after it lands, and the message menu label goes back to its original wording. The assertions only hold while they run inside that window, which is why they passed locally and failed on the slower CI emulator. Remove the four message-menu tests and the two channel swipe tests that depend on that state, together with the selectors and robot actions only they used. They come back in AND-1359 once the mock sends the live own user. Flagging a message stays, because it asserts the dialog and not user state. --- .../android/compose/pages/ChannelListPage.kt | 1 - .../android/compose/pages/MessageListPage.kt | 3 - .../chat/android/compose/robots/UserRobot.kt | 56 ------------- .../robots/UserRobotChannelListAsserts.kt | 9 --- .../robots/UserRobotMessageListAsserts.kt | 25 ------ .../compose/tests/ChannelActionsTests.kt | 39 --------- .../android/compose/tests/ModerationTests.kt | 80 +------------------ 7 files changed, 2 insertions(+), 211 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt index fd2ff36a551..77b4f0e3e2c 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt @@ -52,7 +52,6 @@ class ChannelListPage { companion object { val mute = By.desc("Mute") - val unmute = By.desc("Unmute") val more = By.desc("More") } } 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 78244a68306..548709ca111 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 @@ -171,10 +171,7 @@ open class MessageListPage { val flag get() = By.res("Stream_ContextMenu_Flag Message") val pin get() = By.res("Stream_ContextMenu_Pin to this Chat") val unpin get() = By.res("Stream_ContextMenu_Unpin from this Chat") - val muteUser get() = By.res("Stream_ContextMenu_Mute User") - val unmuteUser get() = By.res("Stream_ContextMenu_Unmute User") val block get() = By.res("Stream_ContextMenu_Block user") - val unblock get() = By.res("Stream_ContextMenu_Unblock user") val delete get() = By.res("Stream_ContextMenu_Delete Message") val showMoreReactions = By.desc("Show more reactions") val ok = By.text("OK") 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 8586c838086..56b53283adf 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 @@ -17,7 +17,6 @@ package io.getstream.chat.android.compose.robots import androidx.test.uiautomator.By -import androidx.test.uiautomator.BySelector import io.getstream.chat.android.compose.pages.ChannelInfoPage import io.getstream.chat.android.compose.pages.ChannelListPage import io.getstream.chat.android.compose.pages.LoginPage @@ -37,7 +36,6 @@ import io.getstream.chat.android.e2e.test.uiautomator.device 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.seconds import io.getstream.chat.android.e2e.test.uiautomator.sleep import io.getstream.chat.android.e2e.test.uiautomator.swipeDown import io.getstream.chat.android.e2e.test.uiautomator.swipeUp @@ -203,24 +201,6 @@ class UserRobot { return this } - /** - * Opens the message menu of the message with [text], reopening it while [option] is missing. - * The menu builds its options when it opens and keeps them while it stays open, so an option - * that flips because of a moderation action shows up only on a later open. - */ - internal fun openContextMenuWithOption(text: String, option: BySelector): UserRobot { - repeat(contextMenuOpenAttempts) { attempt -> - openContextMenu(text) - if (option.waitDisplayed(timeOutMillis = 5.seconds)) { - return this - } - if (attempt < contextMenuOpenAttempts - 1) { - pressBack() - } - } - return this - } - fun flagMessage(text: String): UserRobot { openContextMenu(text) ContextMenu.flag.waitToAppearAndClick() @@ -232,30 +212,6 @@ class UserRobot { return this } - fun muteMessageAuthor(text: String): UserRobot { - openContextMenu(text) - ContextMenu.muteUser.waitToAppearAndClick() - return this - } - - fun unmuteMessageAuthor(text: String): UserRobot { - openContextMenuWithOption(text, ContextMenu.unmuteUser) - ContextMenu.unmuteUser.waitToAppearAndClick() - return this - } - - fun blockMessageAuthor(text: String): UserRobot { - openContextMenu(text) - ContextMenu.block.waitToAppearAndClick() - return this - } - - fun unblockMessageAuthor(text: String): UserRobot { - openContextMenuWithOption(text, ContextMenu.unblock) - ContextMenu.unblock.waitToAppearAndClick() - return this - } - fun pinMessage(messageCellIndex: Int = 0): UserRobot { openContextMenu(messageCellIndex) ContextMenu.pin.waitToAppearAndClick() @@ -445,16 +401,6 @@ class UserRobot { return this } - fun tapOnMuteSwipeAction(): UserRobot { - ChannelListPage.ChannelList.SwipeActions.mute.waitToAppearAndClick() - return this - } - - fun tapOnUnmuteSwipeAction(): UserRobot { - ChannelListPage.ChannelList.SwipeActions.unmute.waitToAppearAndClick() - return this - } - fun tapOnLeaveGroup(): UserRobot { ChannelListPage.ChannelMenu.leaveGroup.waitToAppearAndClick() return this @@ -652,5 +598,3 @@ class UserRobot { return this } } - -private const val contextMenuOpenAttempts = 3 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 61f6368afec..5e8c911217d 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 @@ -80,15 +80,6 @@ fun UserRobot.assertFailedMessageDeliveryStatusInPreview(): UserRobot { return this } -fun UserRobot.assertChannelIsMuted(isMuted: Boolean): UserRobot { - if (isMuted) { - assertTrue(Channel.mutedIcon.waitDisplayed()) - } else { - assertFalse(Channel.mutedIcon.waitToDisappear().isDisplayed()) - } - return this -} - fun UserRobot.assertChannelListIsEmpty(): UserRobot { assertFalse(ChannelList.channels.waitToDisappear().isDisplayed()) 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 9b11eee35cf..54c3c768032 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 @@ -24,7 +24,6 @@ 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.MessageListPage.MessageList.Message.ContextMenu import io.getstream.chat.android.compose.pages.ThreadPage import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.getstream.chat.android.e2e.test.mockserver.ReactionType @@ -548,27 +547,3 @@ fun UserRobot.assertFlagMessageDialog(isDisplayed: Boolean): UserRobot { assertVisibility(MessageListPage.FlagMessageDialog.body, isDisplayed) return this } - -/** - * Opens the message menu of the message with [messageText] and asserts the mute option it offers - * for the author. The sample has no muted users list, so the option label is the only place the - * mute state is visible. - */ -fun UserRobot.assertMuteMessageAuthorOption(messageText: String, isAuthorMuted: Boolean): UserRobot { - val expectedOption = if (isAuthorMuted) ContextMenu.unmuteUser else ContextMenu.muteUser - openContextMenuWithOption(messageText, expectedOption) - assertTrue(expectedOption.isDisplayed()) - return this -} - -/** - * Opens the message menu of the message with [messageText] and asserts the block option it offers - * for the author. The sample has no blocked users list, so the option label is the only place the - * block state is visible. - */ -fun UserRobot.assertBlockMessageAuthorOption(messageText: String, isAuthorBlocked: Boolean): UserRobot { - val expectedOption = if (isAuthorBlocked) ContextMenu.unblock else ContextMenu.block - openContextMenuWithOption(messageText, expectedOption) - assertTrue(expectedOption.isDisplayed()) - return this -} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt index 29c6426f4c0..04b8092100f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelActionsTests.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertChannelActionsSheetForGroupChannel -import io.getstream.chat.android.compose.robots.assertChannelIsMuted import io.getstream.chat.android.compose.robots.assertChannelListIsEmpty import io.getstream.chat.android.compose.robots.assertGroupChannelInfoScreen import io.getstream.chat.android.compose.sample.ui.InitTestActivity @@ -111,42 +110,4 @@ class ChannelActionsTests : StreamTestCase() { userRobot.assertChannelListIsEmpty() } } - - @AllureId("11560") - @Test - fun test_userMutesChannelFromTheSwipeAction() { - step("GIVEN user logs in") { - userRobot.login().waitForChannelListToLoad() - } - step("WHEN user swipes the channel and taps on the mute action") { - userRobot - .swipeChannel() - .tapOnMuteSwipeAction() - } - step("THEN the channel shows the muted icon") { - userRobot.assertChannelIsMuted(isMuted = true) - } - } - - @AllureId("11567") - @Test - fun test_userUnmutesChannelFromTheSwipeAction() { - step("GIVEN user logs in") { - userRobot.login().waitForChannelListToLoad() - } - step("AND user mutes the channel from the swipe action") { - userRobot - .swipeChannel() - .tapOnMuteSwipeAction() - .assertChannelIsMuted(isMuted = true) - } - step("WHEN user swipes the channel and taps on the unmute action") { - userRobot - .swipeChannel() - .tapOnUnmuteSwipeAction() - } - step("THEN the channel shows no muted icon") { - userRobot.assertChannelIsMuted(isMuted = false) - } - } } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt index 30a9b5b4ceb..9bc5d76b905 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt @@ -16,18 +16,16 @@ package io.getstream.chat.android.compose.tests -import io.getstream.chat.android.compose.robots.assertBlockMessageAuthorOption import io.getstream.chat.android.compose.robots.assertFlagMessageDialog import io.getstream.chat.android.compose.robots.assertMessage -import io.getstream.chat.android.compose.robots.assertMuteMessageAuthorOption import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Test /** - * Covers the moderation options the message menu offers for another user's message: flagging the - * message, and muting or blocking its author. The options are absent on the user's own messages. + * Covers flagging another user's message from the message menu. The option is absent on the user's + * own messages. */ class ModerationTests : StreamTestCase() { @@ -58,78 +56,4 @@ class ModerationTests : StreamTestCase() { .assertMessage(sampleText) } } - - @AllureId("11563") - @Test - fun test_userMutesMessageAuthor() { - step("GIVEN user opens the channel") { - userRobot.login().openChannel() - } - step("AND participant sends the message") { - participantRobot.sendMessage(sampleText) - } - step("WHEN user mutes the message author") { - userRobot.muteMessageAuthor(sampleText) - } - step("THEN the message menu offers to unmute the author") { - userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted = true) - } - } - - @AllureId("11566") - @Test - fun test_userUnmutesMessageAuthor() { - step("GIVEN user opens the channel") { - userRobot.login().openChannel() - } - step("AND participant sends the message") { - participantRobot.sendMessage(sampleText) - } - step("AND user mutes the message author") { - userRobot.muteMessageAuthor(sampleText) - } - step("WHEN user unmutes the message author") { - userRobot.unmuteMessageAuthor(sampleText) - } - step("THEN the message menu offers to mute the author again") { - userRobot.assertMuteMessageAuthorOption(sampleText, isAuthorMuted = false) - } - } - - @AllureId("6071") - @Test - fun test_userBlocksMessageAuthor() { - step("GIVEN user opens the channel") { - userRobot.login().openChannel() - } - step("AND participant sends the message") { - participantRobot.sendMessage(sampleText) - } - step("WHEN user blocks the message author") { - userRobot.blockMessageAuthor(sampleText) - } - step("THEN the message menu offers to unblock the author") { - userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked = true) - } - } - - @AllureId("11575") - @Test - fun test_userUnblocksMessageAuthor() { - step("GIVEN user opens the channel") { - userRobot.login().openChannel() - } - step("AND participant sends the message") { - participantRobot.sendMessage(sampleText) - } - step("AND user blocks the message author") { - userRobot.blockMessageAuthor(sampleText) - } - step("WHEN user unblocks the message author") { - userRobot.unblockMessageAuthor(sampleText) - } - step("THEN the message menu offers to block the author again") { - userRobot.assertBlockMessageAuthorOption(sampleText, isAuthorBlocked = false) - } - } }