brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
clicking-and-scrollingUse this skill to drive Jetpack Compose UI from tests with the high-level action APIs that do not go through a gesture builder — performClick, performScrollTo, performScrollToIndex, performScrollToKey, performScrollToNode, requestFocus, performSemanticsAction, and performFirstLinkClick. Covers picking the correct receiver node (the scrollable container vs a…
Independent GitHub snapshot · Automated classification · Original documentation governs
name: clicking-and-scrolling description: Use this skill to drive Jetpack Compose UI from tests with the high-level action APIs that do not go through a gesture builder — performClick, performScrollTo, performScrollToIndex, performScrollToKey, performScrollToNode, requestFocus, performSemanticsAction, and performFirstLinkClick. Covers picking the correct receiver node (the scrollable container vs an item), the matchers used to find a scroll parent (hasScrollAction, hasScrollToIndexAction, hasScrollToKeyAction, hasScrollToNodeAction), and how the lazy vs non-lazy scrollable cases differ. Use when the developer asks "how do I tap a Compose node", "scroll to an item in a LazyColumn", "click a link inside Text", "trigger a custom semantics action", or reports "Action performScrollTo failed" / "node has no parent layout with a Scroll SemanticsAction" / "ScrollToIndex not defined". license: Apache-2.0. See LICENSE for complete terms. metadata: author: Jaewoong Eum (skydoves) keywords:
Compose's high-level actions resolve to platform-appropriate primitives or to semantics actions that the composable already exposes. Use them whenever the test does not need pixel-level control over the gesture. Reach for performTouchInput { … } (see ../injecting-touch-gestures/SKILL.md) only when the high-level action cannot express the intent.
LazyColumn / LazyRow to a specific index, key, or matched item.AccessibilityAction<T> declared via Modifier.semantics { … }.LinkAnnotation inside a Text.../injecting-touch-gestures/SKILL.md.../injecting-mouse-and-keyboard/SKILL.md.../entering-text/SKILL.md.../../finders/finding-nodes-by-tag-text-content/SKILL.md and ../../debug/printing-the-semantics-tree/SKILL.md.androidx.compose.ui:ui-test-junit4 (or androidx.compose.ui:ui-test for runComposeUiTest) configured per ../../setup/configuring-test-dependencies/SKILL.md.Modifier.testTag("…") from production source — skydoves hot take #1.performScrollToIndex / performScrollToKey: the container must implement the relevant semantics actions. LazyColumn / LazyRow do; a plain Modifier.verticalScroll does not.performFirstLinkClick: the Text must use an AnnotatedString carrying a LinkAnnotation.Pick the right receiver node. This is the most common failure mode. performClick / performScrollTo / performFirstLinkClick run on a content node; performScrollToIndex / performScrollToKey / performScrollToNode run on the scrollable container. Confirm with ../../debug/printing-the-semantics-tree/SKILL.md if unsure.
Click a node — performClick(). On Android (both device tests and Robolectric host tests) it delegates to performTouchInput { click() } via the Android actual (Actions.android.kt:22-24). The mouse-click path lives in JetBrains' Compose Multiplatform desktop fork. Common API at Actions.kt:49-62.
rule.onNodeWithTag(SubmitButtonTag).performClick()
performScrollTo(). Walks up the tree to the closest parent carrying SemanticsActions.ScrollBy and scrolls by the smallest delta needed to put the node fully in the viewport (Actions.kt:78-87). Throws AssertionError if no scroll parent exists.rule.onNodeWithTag(LastItemTag).performScrollTo().assertIsDisplayed()
performScrollToIndex(index). Requires SemanticsActions.ScrollToIndex on the receiver node (Actions.kt:159-172). The receiver MUST be the container (the LazyColumn itself), not an item.rule.onNode(hasScrollToIndexAction()).performScrollToIndex(42)
performScrollToKey(key). Requires both IndexForKey and ScrollToIndex (Actions.kt:188-203). Matches the key = { … } parameter of LazyColumn / LazyRow items.rule.onNode(hasScrollToKeyAction()).performScrollToKey("user-7")
performScrollToNode(matcher). Walks the lazy container viewport-by-viewport from start to end. For non-lazy scrollables, falls back to performScrollTo once the node materializes (Actions.kt:233-268). Throws when end-of-content is reached without a match.rule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("End of feed"))
requestFocus(). Sugar for performSemanticsAction(SemanticsActions.RequestFocus) (Actions.kt:600-601). Required before performIndirectPointerInput, and useful for BasicTextField setups that bypass performTextInput's auto-focus path.rule.onNodeWithTag(InputTag).requestFocus()
AccessibilityAction<T> — performSemanticsAction(key, invocation) for parameterized actions, performSemanticsAction(key) for nullary ones (Actions.kt:631-672). The action MUST be declared on the node via Modifier.semantics { … }; otherwise an AssertionError is thrown.val MyAction = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("MyAction")
// production:
Modifier.semantics { this[MyAction] = AccessibilityAction("Bump") { delta -> bump(delta); true } }
// test:
rule.onNodeWithTag(Tag).performSemanticsAction(MyAction) { it(3) }
LinkAnnotation inside Text — performFirstLinkClick(predicate) (Actions.kt:777-804). The default predicate { true } clicks the first link. Throws when the receiver has no text or when no link satisfies the predicate.rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }
// WRONG
rule.onNodeWithTag("item-7").performScrollToIndex(7)
// WRONG because: performScrollToIndex requires the ScrollToIndex semantics action,
// which lives on the LazyColumn container, not on its items. AssertionError:
// "Failed to scroll to index 7, the node is missing [ScrollToIndex]".
// RIGHT
rule.onNode(hasScrollToIndexAction()).performScrollToIndex(7)
rule.onNodeWithTag("item-7").assertIsDisplayed()
The matcher hasScrollToIndexAction() is defined in Filters.kt and identifies any container exposing SemanticsActions.ScrollToIndex. Tag the container too if multiple lazy lists exist on screen.
// WRONG
rule.onNodeWithTag(BottomBannerTag).assertIsDisplayed()
// WRONG because: a Column inside Modifier.verticalScroll renders all children, but
// children outside the viewport are clipped — assertIsDisplayed fails because
// the visible bounds intersect the viewport at zero pixels.
// RIGHT
rule.onNodeWithTag(BottomBannerTag).performScrollTo().assertIsDisplayed()
performScrollTo scans up to the closest hasScrollAction() parent and scrolls by the smallest delta needed (Actions.kt:95-141). For lazy lists, prefer performScrollToNode since the target item may not yet be composed.
// RIGHT
rule.onNode(hasScrollToNodeAction()).performScrollToNode(
hasText("Privacy Policy", substring = true)
)
The matcher hasScrollToNodeAction() accepts both lazy and non-lazy scrollables (Filters.kt). For lazy containers, performScrollToNode rewinds to index 0 first and walks viewport-sized steps until the matcher hits.
Production:
val Bump = SemanticsPropertyKey<AccessibilityAction<(Int) -> Boolean>>("Bump")
@Composable
fun Counter(value: Int, onBump: (Int) -> Unit) {
Box(
Modifier
.testTag("counter")
.semantics {
this[Bump] = AccessibilityAction("Bump") { delta ->
onBump(delta); true
}
}
) { Text(value.toString()) }
}
Test:
@Test fun bumpAction_increments() {
var value by mutableIntStateOf(0)
rule.setContent { Counter(value) { value += it } }
rule.onNodeWithTag("counter").performSemanticsAction(Bump) { it(5) }
rule.runOnIdle { assertEquals(5, value) }
}
This is the cleanest way to test logic that does not have a built-in action like OnClick — no need to fabricate touch coordinates or to depend on the layout being clickable.
// RIGHT
@Test fun privacyLink_navigates() {
var clicked = false
rule.setContent {
Text(
buildAnnotatedString {
append("Read our ")
withLink(LinkAnnotation.Url("https://example.com/privacy") {
clicked = true
}) { append("Privacy Policy") }
},
modifier = Modifier.testTag(BodyTag),
)
}
rule.onNodeWithTag(BodyTag).performFirstLinkClick { it.item is LinkAnnotation.Url }
rule.runOnIdle { assertTrue(clicked) }
}
performFirstLinkClick first asserts the node has text, collects every LinkAnnotation in the AnnotatedString, picks the first that satisfies the predicate, then dispatches OnClick on the corresponding link child (Actions.kt:777-804).
// RIGHT
rule.onNodeWithTag(SurfaceTag).requestFocus()
rule.performIndirectPointerInput( // extension on SemanticsNodeInteractionsProvider, not on a node
indirectPointerEventPrimaryDirectionalMotionAxis = Vertical,
inputDeviceSize = IntSize(1000, 1000),
) {
// events go to the focused tree
}
The public performIndirectPointerInput extension hangs off SemanticsNodeInteractionsProvider (Actions.kt:862) — i.e. the rule (or ComposeUiTest) itself. The same-name overload on SemanticsNodeInteraction is internal (Actions.kt:942), so rule.onRoot().performIndirectPointerInput(...) does NOT compile from consumer code.
Indirect pointer input dispatches through the focus path, so an explicit requestFocus is mandatory (Actions.kt:807-875). For ordinary touch tests, focus is not required.
performScrollToIndex, performScrollToKey, and performScrollToNode on the scrollable container, not on an item — the semantics actions live on the container. Otherwise AssertionError: the node is missing [ScrollToIndex].performScrollTo on a content node, not on the container — performScrollTo walks up to find the scroll parent.Modifier.testTag("…") whose value is a constant defined in production source — skydoves hot take #1. Text and content-description finders are i18n-fragile.runOnIdle { … } (skydoves hot take #5). Reading state directly from the test thread races with the recomposer.performScrollTo on a LazyColumn item — the item probably is not even composed. Use performScrollToIndex / performScrollToKey / performScrollToNode on the container.performClick for hover, right-click, or wheel scroll. Use the modality-specific scope from ../injecting-mouse-and-keyboard/SKILL.md.hasScrollAction() / hasScrollToIndexAction() / hasScrollToKeyAction() / hasScrollToNodeAction() (Filters.kt) when there is a single such container on screen, instead of adding a redundant test tag to it.performScrollToIndex / performScrollToKey / performScrollToNode call targets the container — not an item — and a tag or hasScrollToIndexAction() matcher selects it.performClick is used to simulate hover, right-click, or scroll wheel.performSemanticsAction call references a SemanticsPropertyKey that the production code installs via Modifier.semantics { … }.rule.runOnIdle { … }../gradlew :app:connectedDebugAndroidTest (or :app:testDebugUnitTest for Robolectric) passes for the test under change.compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Actions.kt — performClick (Actions.kt:58), performScrollTo (Actions.kt:78), performScrollToIndex (Actions.kt:159), performScrollToKey (Actions.kt:188), performScrollToNode (Actions.kt:233), requestFocus (Actions.kt:600), performSemanticsAction (Actions.kt:631 / Actions.kt:668), performFirstLinkClick (Actions.kt:777).compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/Filters.kt — hasScrollAction, hasScrollToIndexAction, hasScrollToKeyAction, hasScrollToNodeAction.compose/foundation/foundation/integration-tests/lazy-tests/.../LazyListTest.kt — canonical performScrollToIndex / performScrollToKey patterns.compose/foundation/foundation/src/androidDeviceTest/.../CombinedClickableTest.kt — performClick, performTouchInput { longClick() }.