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..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 @@ -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 { @@ -72,6 +82,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..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 @@ -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 { 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..a6bb59d40b7 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/RemindersPage.kt @@ -0,0 +1,41 @@ +/* + * 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)) + + /** 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/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..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,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,17 @@ 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 pinMessage(messageCellIndex: Int = 0): UserRobot { openContextMenu(messageCellIndex) ContextMenu.pin.waitToAppearAndClick() @@ -381,11 +401,59 @@ class UserRobot { 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..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 @@ -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,11 @@ fun UserRobot.assertFailedMessageDeliveryStatusInPreview(): UserRobot { 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..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 @@ -542,3 +542,8 @@ fun UserRobot.assertLinkPreviewInComposer(isDisplayed: Boolean): UserRobot { } return this } + +fun UserRobot.assertFlagMessageDialog(isDisplayed: Boolean): UserRobot { + assertVisibility(MessageListPage.FlagMessageDialog.body, isDisplayed) + 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..7b40a1e9acc --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotRemindersAsserts.kt @@ -0,0 +1,46 @@ +/* + * 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.RemindersPage +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 { + assertTrue(RemindersPage.title.waitDisplayed()) + return this +} + +fun UserRobot.assertReminder(messageText: String): UserRobot { + assertTrue(RemindersPage.reminder(messageText).waitDisplayed()) + return this +} + +fun UserRobot.assertReminderSavedForLater(messageText: String): UserRobot { + // 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 +} + +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..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,6 +17,7 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertChannelActionsSheetForGroupChannel +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 +72,42 @@ class ChannelActionsTests : StreamTestCase() { userRobot.assertGroupChannelInfoScreen() } } + + @AllureId("11569") + @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() + } + } + + @AllureId("11573") + @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() + } + } } 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..9bc5d76b905 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ModerationTests.kt @@ -0,0 +1,59 @@ +/* + * 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.assertFlagMessageDialog +import io.getstream.chat.android.compose.robots.assertMessage +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 flagging another user's message from the message menu. The option is absent on the user's + * own messages. + */ +class ModerationTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + private val sampleText = "Test" + + @AllureId("11572") + @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) + } + } +} 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..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 @@ -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,61 @@ class PinnedMessagesTests : StreamTestCase() { } } + @AllureId("11568") + @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) + } + } + + @AllureId("11565") + @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..7b799905256 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/RemindersTests.kt @@ -0,0 +1,101 @@ +/* + * 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 io.qameta.allure.kotlin.AllureId +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 + + @AllureId("11574") + @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() + } + } + + @AllureId("11562") + @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) + } + } + + @AllureId("11571") + @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..15b7dfb7a3f --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/SearchTests.kt @@ -0,0 +1,78 @@ +/* + * 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) + } + } + + @AllureId("11561") + @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..95f3af4db35 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ThreadListTests.kt @@ -0,0 +1,103 @@ +/* + * 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 io.qameta.allure.kotlin.AllureId +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" + + @AllureId("11564") + @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() + } + } + + @AllureId("11570") + @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) + } + } + + @AllureId("11576") + @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/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), ) { 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")