From d96eecfe7263a656811a38fb6989f97a147d1837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 14:55:48 +0100 Subject: [PATCH 1/6] compose: Wait out an in-progress scroll before jumping to a focused message The focused-message scroll effect skipped the jump when the list was still scrolling, and the focused state is consumed either way, so a jump requested while the list settles was silently dropped and never retried. Wait for the scroll to end instead of skipping. --- .../chat/android/compose/ui/messages/list/Messages.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt index 641ef16262b..17611c7219d 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt @@ -75,6 +75,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.Typing import io.getstream.chat.android.ui.common.state.messages.list.UnreadSeparatorItemState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.abs @@ -385,9 +386,11 @@ internal fun BoxScope.DefaultMessagesHelperContent( val offset = messagesLazyListState.focusedMessageOffset LaunchedEffect(focusedItemIndex, offset) { - if (focusedItemIndex != -1 && - !lazyListState.isScrollInProgress - ) { + if (focusedItemIndex != -1) { + // The tap on a quoted message often lands while the list is still settling from a + // previous scroll. Wait the scroll out instead of dropping the jump: the focused + // state is consumed either way, so a dropped jump would never be retried. + snapshotFlow { lazyListState.isScrollInProgress }.first { inProgress -> !inProgress } lazyListState.animateScrollToItem(focusedItemIndex, offset) } } From 9fe5b93be7ed043c994ded3c67f4679aca441c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 14:56:00 +0100 Subject: [PATCH 2/6] e2e: Add a stale-safe click for objects matched by index waitToAppearAndClick only covered single-object selectors; clicks on an object picked by index from findObjects still threw StaleObjectException when the containing list refreshed between the find and the click. Mirror the same re-find and retry contract for the indexed lookup. --- .../chat/android/e2e/test/uiautomator/Wait.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index 87ca60f14cd..d696effdb9f 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -66,6 +66,28 @@ public fun BySelector.waitToAppearAndClick(timeOutMillis: Long = defaultTimeout) } } +/** + * Waits up to [timeOutMillis] for objects matching this selector and clicks the one at + * [withIndex]. When the click lands on a node that went stale between the find and the click + * (e.g. because the containing list refreshed), the object is re-found and the click retried + * until the timeout, after which the last [StaleObjectException] escapes. + * + * @param withIndex The zero-based index of the object to click. + * @param timeOutMillis Maximum time to wait before failing. + * @throws IllegalStateException when the timeout elapses without enough matching objects. + */ +public fun BySelector.waitToAppearAndClick(withIndex: Int, timeOutMillis: Long = defaultTimeout) { + val endTime = System.currentTimeMillis() + timeOutMillis + while (true) { + try { + waitToAppear(withIndex, maxOf(endTime - System.currentTimeMillis(), POLL_INTERVAL_MILLIS)).click() + return + } catch (e: StaleObjectException) { + if (System.currentTimeMillis() >= endTime) throw e + } + } +} + /** * Waits up to [timeOutMillis] for objects matching this selector and returns the one at [withIndex]. * From d9a5b2a16d23d32fe7e3ff8724dc433c8fcc2cc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 14:56:00 +0100 Subject: [PATCH 3/6] e2e: Stabilize the thread quote jump test with the scrolling setup Three failure modes made the test flaky. The fixed count of eight scroll-ups could leave the target message clipped at the viewport edge, where its text is not laid out and a long press does nothing: scroll until the message is fully inside the viewport instead. A tap on the quoted message that lands while the list is still moving is cancelled by the touch slop and never reaches the click handler: verify the jump moved the quote out of the viewport and tap again when it did not. The channel click at the start of the test could hit a stale node: open the channel through the stale-safe indexed click. Also fixes the MessageListPage list selector, which pointed at a resource id that does not exist. --- .../chat/android/compose/robots/UserRobot.kt | 42 ++++++++++++++++++- .../android/compose/tests/QuotedReplyTests.kt | 4 +- 2 files changed, 41 insertions(+), 5 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 c758e61a39f..923c3ca76f5 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 @@ -34,10 +34,12 @@ 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.sleep import io.getstream.chat.android.e2e.test.uiautomator.swipeDown import io.getstream.chat.android.e2e.test.uiautomator.swipeUp import io.getstream.chat.android.e2e.test.uiautomator.typeText import io.getstream.chat.android.e2e.test.uiautomator.wait +import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear import io.getstream.chat.android.e2e.test.uiautomator.waitToAppearAndClick import io.getstream.chat.android.e2e.test.uiautomator.waitToAppearBottomUp @@ -71,7 +73,7 @@ class UserRobot { } fun openChannel(channelCellIndex: Int = 0): UserRobot { - ChannelListPage.ChannelList.channels.wait().findObjects()[channelCellIndex].click() + ChannelListPage.ChannelList.channels.waitToAppearAndClick(withIndex = channelCellIndex) return this } @@ -279,7 +281,16 @@ class UserRobot { } fun tapOnQuotedMessage(messageCellIndex: Int = 0): UserRobot { - Message.quotedMessage.waitToAppearAndClick() + // A tap that lands while the list is still moving is cancelled by the touch slop and + // never reaches the click handler, so verify the jump moved the quote out of the + // viewport and tap again when it did not. Does not fail on its own: the assertion + // that follows reports a jump that never happened. + repeat(3) { + Message.quotedMessage.waitToAppearAndClick() + if (!Message.quotedMessage.waitToDisappear(timeOutMillis = 3_000).isDisplayed()) { + return this + } + } return this } @@ -385,6 +396,33 @@ class UserRobot { return this } + /** + * Scrolls the message list up one page at a time until the message with [messageText] is + * fully inside the list viewport, giving up after [maxScrolls] pages. The first sighting is + * not enough: a message clipped by the viewport edge cannot be long pressed, so a target + * that is still clipped after the scroll settles counts as not reached yet. Does not fail + * on its own: the interaction that follows reports the missing message. + */ + fun scrollMessageListUpToMessage(messageText: String, maxScrolls: Int = 10): UserRobot { + val message = Message.text + .text(messageText) + .hasAncestor(MessageList.messages) + val listTop = MessageList.messageList.waitToAppear().visibleBounds.top + repeat(maxScrolls) { + if (message.waitDisplayed(timeOutMillis = 1_000)) { + sleep(500) // let the fling settle before trusting the bounds + val fullyVisible = runCatching { + message.findObjects().firstOrNull()?.visibleBounds?.let { it.top > listTop } == true + }.getOrDefault(false) + if (fullyVisible) { + return this + } + } + scrollMessageListUp(times = 1) + } + return this + } + fun swipeMessage(messageCellIndex: Int = 0): UserRobot { val percent = 0.5f val message = MessageList.messages.waitToAppearBottomUp(withIndex = messageCellIndex) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt index ccc17bdec44..54a11613acd 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/QuotedReplyTests.kt @@ -31,7 +31,6 @@ import io.getstream.chat.android.e2e.test.mockserver.AttachmentType import io.getstream.chat.android.e2e.test.uiautomator.appContext import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId -import org.junit.Ignore import org.junit.Test import io.getstream.chat.android.ui.common.R as UiCommonR @@ -363,7 +362,6 @@ class QuotedReplyTests : StreamTestCase() { } @AllureId("5892") - @Ignore("https://linear.app/stream/issue/AND-1332") @Test fun test_quotedReplyNotInList_whenUserAddsQuotedReply_InThread() { step("GIVEN user opens the channel") { @@ -373,7 +371,7 @@ class QuotedReplyTests : StreamTestCase() { step("WHEN user adds a quoted reply to message in thread") { userRobot .openThread() - .scrollMessageListUp(times = 8) + .scrollMessageListUpToMessage(firstMessage) .quoteMessage(quoteReply, quotedMessageText = firstMessage) } step("THEN user observes the quote reply in thread") { From 557491c68464a02ab37c0b88a9f4a38f3c4093fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 15:27:11 +0100 Subject: [PATCH 4/6] compose: Extract the focused-item scroll and cover it with tests Moves the body of the focused-message scroll effect into an internal LazyListState extension so the guard, the idle jump, and the wait-out-an-ongoing-scroll behavior can be exercised in unit tests. --- .../compose/ui/messages/list/Messages.kt | 20 ++-- .../messages/list/ScrollToFocusedItemTest.kt | 104 ++++++++++++++++++ 2 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt index 17611c7219d..44af8911437 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/Messages.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -347,6 +348,17 @@ private fun MessageListState.getVerticalArrangement( ): Arrangement.Vertical = if (parentMessageId != null) threadsVerticalArrangement else messagesVerticalArrangement +/** + * Scrolls to the focused item at [focusedItemIndex], or returns right away when there is none + * (index -1). A scroll that is still in progress is waited out first instead of skipping the + * jump: the focused state is consumed either way, so a skipped jump would never be retried. + */ +internal suspend fun LazyListState.scrollToFocusedItem(focusedItemIndex: Int, offset: Int) { + if (focusedItemIndex == -1) return + snapshotFlow { isScrollInProgress }.first { inProgress -> !inProgress } + animateScrollToItem(focusedItemIndex, offset) +} + /** * Represents the default scrolling behavior and UI for [Messages], based on the state of messages and the scroll state. * @@ -386,13 +398,7 @@ internal fun BoxScope.DefaultMessagesHelperContent( val offset = messagesLazyListState.focusedMessageOffset LaunchedEffect(focusedItemIndex, offset) { - if (focusedItemIndex != -1) { - // The tap on a quoted message often lands while the list is still settling from a - // previous scroll. Wait the scroll out instead of dropping the jump: the focused - // state is consumed either way, so a dropped jump would never be retried. - snapshotFlow { lazyListState.isScrollInProgress }.first { inProgress -> !inProgress } - lazyListState.animateScrollToItem(focusedItemIndex, offset) - } + lazyListState.scrollToFocusedItem(focusedItemIndex, offset) } // Keep track of the last new message state that triggered a scroll to bottom. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt new file mode 100644 index 00000000000..166a606fe94 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt @@ -0,0 +1,104 @@ +/* + * 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.ui.messages.list + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class ScrollToFocusedItemTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var listState: LazyListState + private lateinit var scope: CoroutineScope + + private fun setListContent() { + composeTestRule.setContent { + listState = rememberLazyListState() + scope = rememberCoroutineScope() + LazyColumn(state = listState, modifier = Modifier.size(200.dp)) { + items(count = 200) { + Box(modifier = Modifier.size(50.dp)) + } + } + } + } + + @Test + fun `given no focused item, the list does not scroll`() { + setListContent() + + val jump = scrollToFocusedItemAsync(focusedItemIndex = -1) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted } + assertEquals(0, listState.firstVisibleItemIndex) + } + + @Test + fun `given an idle list, scrolls to the focused item`() { + setListContent() + + val jump = scrollToFocusedItemAsync(focusedItemIndex = 100) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted } + assertEquals(100, listState.firstVisibleItemIndex) + } + + @Test + fun `given a scroll in progress, waits it out and then scrolls to the focused item`() { + setListContent() + composeTestRule.runOnIdle { + scope.launch { + listState.animateScrollBy(value = 500f, animationSpec = tween(durationMillis = 1_000)) + } + } + + val jump = scrollToFocusedItemAsync(focusedItemIndex = 100) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted } + assertEquals(100, listState.firstVisibleItemIndex) + } + + private fun scrollToFocusedItemAsync(focusedItemIndex: Int): Deferred = + composeTestRule.runOnIdle { + scope.async { + listState.scrollToFocusedItem(focusedItemIndex = focusedItemIndex, offset = 0) + } + } +} From 6de26dc13327e9920956d8de9bf06717544a926c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 15:27:11 +0100 Subject: [PATCH 5/6] e2e: Clarify the scroll and click helper contracts States why scrollMessageListUpToMessage only checks the top edge of the list viewport, and documents the final grace poll of the indexed waitToAppearAndClick the same way its selector-based sibling does. --- .../io/getstream/chat/android/compose/robots/UserRobot.kt | 8 +++++--- .../getstream/chat/android/e2e/test/uiautomator/Wait.kt | 4 +++- 2 files changed, 8 insertions(+), 4 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 923c3ca76f5..2b48a6b7982 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 @@ -398,9 +398,11 @@ class UserRobot { /** * Scrolls the message list up one page at a time until the message with [messageText] is - * fully inside the list viewport, giving up after [maxScrolls] pages. The first sighting is - * not enough: a message clipped by the viewport edge cannot be long pressed, so a target - * that is still clipped after the scroll settles counts as not reached yet. Does not fail + * displayed and clear of the top edge of the list, giving up after [maxScrolls] pages. The + * first sighting is not enough: a message still clipped by the top edge after the scroll + * settles has no laid-out text to long press, so it counts as not reached yet. Only the top + * edge matters: scrolling up moves content downwards, so it is the edge the target enters + * from, and a text clipped by the bottom edge is laid out and can be pressed. Does not fail * on its own: the interaction that follows reports the missing message. */ fun scrollMessageListUpToMessage(messageText: String, maxScrolls: Int = 10): UserRobot { diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt index d696effdb9f..9581fb1a9ed 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Wait.kt @@ -70,7 +70,9 @@ public fun BySelector.waitToAppearAndClick(timeOutMillis: Long = defaultTimeout) * Waits up to [timeOutMillis] for objects matching this selector and clicks the one at * [withIndex]. When the click lands on a node that went stale between the find and the click * (e.g. because the containing list refreshed), the object is re-found and the click retried - * until the timeout, after which the last [StaleObjectException] escapes. + * until the timeout, after which the last [StaleObjectException] escapes. A retry started just + * before the deadline is granted one final poll interval past it, so the last re-find is a + * real attempt instead of an immediate timeout. * * @param withIndex The zero-based index of the object to click. * @param timeOutMillis Maximum time to wait before failing. From 48260258c0516a9f1fdc6091ad4768f474dba918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Jul 2026 11:14:58 +0100 Subject: [PATCH 6/6] compose: Pause the clock so the scroll wait test exercises the wait With the clock auto-advancing, runOnIdle finished the scroll animation before scrollToFocusedItem started, so the test passed even with the wait reverted to the old skip behavior. Pause the main clock, launch the jump in the middle of the animation, and assert it stays parked until the scroll ends. --- .../messages/list/ScrollToFocusedItemTest.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt index 166a606fe94..a3104d76f21 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/list/ScrollToFocusedItemTest.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -83,14 +85,31 @@ internal class ScrollToFocusedItemTest { @Test fun `given a scroll in progress, waits it out and then scrolls to the focused item`() { setListContent() - composeTestRule.runOnIdle { + // Pause the clock so the animation genuinely overlaps the jump. With the clock + // auto-advancing, runOnIdle would finish the animation before the jump even starts and + // the test would pass without exercising the wait. Assertions use runOnUiThread because + // runOnIdle cannot settle while the clock is paused mid-animation. + composeTestRule.mainClock.autoAdvance = false + composeTestRule.runOnUiThread { scope.launch { - listState.animateScrollBy(value = 500f, animationSpec = tween(durationMillis = 1_000)) + listState.animateScrollBy(value = 1_000f, animationSpec = tween(durationMillis = 2_000)) } } + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.runOnUiThread { assertTrue(listState.isScrollInProgress) } - val jump = scrollToFocusedItemAsync(focusedItemIndex = 100) + val jump = composeTestRule.runOnUiThread { + scope.async { listState.scrollToFocusedItem(focusedItemIndex = 100, offset = 0) } + } + + composeTestRule.mainClock.advanceTimeBy(500) + composeTestRule.runOnUiThread { + assertTrue(listState.isScrollInProgress) + assertFalse(jump.isCompleted) + assertTrue(listState.firstVisibleItemIndex < 100) + } + composeTestRule.mainClock.autoAdvance = true composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted } assertEquals(100, listState.firstVisibleItemIndex) }