From 8bbf5236ef9aa91fe0420c646ee52896e0e1a341 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 10 Jun 2026 00:25:26 +0200 Subject: [PATCH 1/9] + SelectionContainer tests --- .../SelectionContainerInteractionTest.kt | 431 ++++++++++++++++++ 1 file changed, 431 insertions(+) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt new file mode 100644 index 0000000000000..39a324439e119 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -0,0 +1,431 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 androidx.compose.ui.interaction + +import androidx.compose.foundation.ComposeFoundationFlags +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.selection.DisableSelection +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.text.selection.SelectionState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.assertVisibleInContainer +import androidx.compose.ui.test.findFocusedUITextInput +import androidx.compose.ui.test.findNodeWithLabel +import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.findNodeWithTagOrNull +import androidx.compose.ui.test.firstNodeOrNull +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.utils.findFirstDescendant +import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.isLoupeView +import androidx.compose.ui.test.utils.up +import androidx.compose.ui.test.waitForContextMenu +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available +import platform.UIKit.UIPasteboard + +class SelectionContainerInteractionTest { + + @Test + fun testSelectionContainer_LongPressSelectsWord() = runUIKitInstrumentedTest { + val selectionState = SelectionState() + val text = "accomplishment" + + setSelectionContainerContent(state = selectionState, text = text) + + longPressAndReleaseAfterLoupe(Tag) + + waitUntil("SelectionContainer should select the word after long press") { + selectionState.selectedText() == text + } + + assertEquals( + listOf(text), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_DoubleTapSelectsWord() = runUIKitInstrumentedTest { + val selectionState = SelectionState() + val text = "accomplishment" + + setSelectionContainerContent(state = selectionState, text = text) + + focusThenDoubleTap() + waitUntil("SelectionContainer should select the word after double tap") { + selectionState.selectedText() == text + } + + assertEquals( + listOf(text), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_LongPressDragExtendsSelectionAcrossLines() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val firstLine = "accomplishment" + + setSelectionContainerContent( + state = selectionState, + text = "$firstLine\nmagnificent", + contentWidth = 160.dp, + ) + + longPressAndDrag( + startTag = Tag, + endTag = Tag, + startXFraction = 0.12f, + startYFraction = 0.25f, + endXFraction = 0.80f, + endYFraction = 0.75f, + ) + + waitUntil("SelectionContainer should extend the selection across lines") { + val selectedText = selectionState.selectedText() + selectedText.contains("\n") && + selectedText.substringAfter('\n').isNotEmpty() + } + + val selectedText = selectionState.selectedText() + assertTrue( + selectedText.startsWith(firstLine), + "Expected selection to start from the first line, but got: $selectedText", + ) + } + + @Test + fun testSelectionContainer_LongPressDragExtendsSelectionAcrossTextChildren() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val firstText = "accomplishment" + val secondText = "magnificent" + + setSelectionContainerContent(state = selectionState) { + Column { + BasicText( + text = firstText, + modifier = Modifier.width(SelectableTextWidth).testTag(FirstTextTag), + ) + BasicText( + text = secondText, + modifier = Modifier.width(SelectableTextWidth).testTag(SecondTextTag), + ) + } + } + + longPressAndDrag( + startTag = FirstTextTag, + endTag = SecondTextTag, + startXFraction = 0.02f, + endXFraction = 0.98f, + ) + + waitUntil("SelectionContainer should extend selection across text children") { + selectionState.selectedText() == firstText + secondText + } + + assertEquals( + listOf(firstText, secondText), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_LongPressDragSkipsDisableSelectionSubtree() = + runUIKitInstrumentedTest { + val selectionState = SelectionState() + val textBeforeDisabled = "accomplishment" + val textAfterDisabled = "remarkable" + val textAfterDisabledTag = "SelectionContainerTextAfterDisabled" + + setSelectionContainerContent(state = selectionState) { + Column { + BasicText( + text = textBeforeDisabled, + modifier = Modifier.width(SelectableTextWidth).testTag(FirstTextTag), + ) + DisableSelection { + BasicText( + text = "hidden", + modifier = + Modifier.width(SelectableTextWidth) + .testTag("SelectionContainerDisabledText"), + ) + } + BasicText( + text = textAfterDisabled, + modifier = + Modifier.width(SelectableTextWidth).testTag(textAfterDisabledTag), + ) + } + } + + longPressAndDrag( + startTag = FirstTextTag, + endTag = textAfterDisabledTag, + startXFraction = 0.02f, + endXFraction = 0.98f, + ) + + waitUntil("SelectionContainer should skip DisableSelection content during drag") { + selectionState.selectedText() == textBeforeDisabled + textAfterDisabled + } + + assertEquals( + listOf(textBeforeDisabled, textAfterDisabled), + selectionState.selectedTexts.map { it.text }, + ) + } + + @Test + fun testSelectionContainer_CopyCopiesExactSelectedText() = + runSelectionContainerContextMenuTest { + UIPasteboard.generalPasteboard().string = "Clipboard sentinel" + val selectionState = SelectionState() + val firstWord = "copyable" + val text = "$firstWord second" + + setSelectionContainerContent(state = selectionState, text = text) + + awaitNodeLaidOut(Tag) + openToolbarForLeadingWord(Tag) + waitUntil("SelectionContainer should create the expected word selection before Copy") { + selectionState.selectedText() == firstWord + } + findNodeWithLabel("Copy").assertVisibleInContainer() + tapContextMenuButton("Copy") + + waitUntil("Pasteboard should contain the copied SelectionContainer text") { + UIPasteboard.generalPasteboard().string == firstWord + } + + val selectionAfterCopy = selectionState.selectedText() + assertTrue( + selectionAfterCopy.isEmpty() || selectionAfterCopy == firstWord, + "Expected SelectionContainer selection to either clear or preserve the copied word after Copy, but was: $selectionAfterCopy", + ) + } + + @Test + fun testSelectionContainer_EmptyTextDoesNotOpenMenu() = runSelectionContainerContextMenuTest { + val selectionState = SelectionState() + + setSelectionContainerContent(state = selectionState, text = "") + + awaitNodeLaidOut(Tag) + findNodeWithTag(Tag).longPress() + waitForIdle() + delay(EmptyTextLongPressSettleDelayMillis) + + assertTrue( + selectionState.selectedTexts.isEmpty(), + "Expected empty text content to produce no selection.", + ) + assertNoContextMenu() + } + + @Test + fun testSelectionContainer_OpeningMenuDoesNotShowKeyboard() = + runSelectionContainerContextMenuTest { + val selectionState = SelectionState() + + setSelectionContainerContent(state = selectionState, text = "copyable second") + + awaitNodeLaidOut(Tag) + focusThenDoubleTap() + waitForContextMenu() + waitUntil("SelectionContainer should create a selection before menu open") { + selectionState.selectedTexts.isNotEmpty() + } + + findNodeWithLabel("Copy").assertVisibleInContainer() + assertNull( + findFocusedUITextInput(), + "Expected SelectionContainer menu to open without focusing a UITextInput.", + ) + assertEquals(0.dp, keyboardHeight) + } + + private fun UIKitInstrumentedTest.setSelectionContainerContent( + state: SelectionState, + content: @Composable () -> Unit, + ) { + setContent { + Box(modifier = Modifier.fillMaxSize()) { + SelectionContainer( + state = state, + modifier = Modifier.align(Alignment.Center).testTag(Tag), + ) { + content() + } + } + } + } + + private fun UIKitInstrumentedTest.setSelectionContainerContent( + state: SelectionState, + text: String, + contentWidth: Dp = 320.dp, + ) { + setSelectionContainerContent(state = state) { + BasicText(text = text, modifier = Modifier.width(contentWidth)) + } + } + + private fun UIKitInstrumentedTest.longPressAndReleaseAfterLoupe(tag: String) { + val touch = findNodeWithTag(tag).touchDown() + waitUntil("Selection loupe should appear after long press") { + findFirstDescendant { it.isLoupeView } != null + } + touch.up() + } + + private fun UIKitInstrumentedTest.awaitNodeLaidOut(tag: String) { + waitUntil("Node with tag $tag should be laid out") { + findNodeWithTagOrNull(tag)?.frame != null + } + } + + private fun UIKitInstrumentedTest.openToolbarForLeadingWord(tag: String) { + findNodeWithTag(tag).tap() + delay(DoubleTapPreparationDelayMillis) + val tapPoint = pointInNode(tag, xFraction = 0.1f, yFraction = 0.5f) + tap(tapPoint) + delay(ManualDoubleTapIntervalDelayMillis) + tap(tapPoint) + waitForContextMenu() + } + + private fun UIKitInstrumentedTest.focusThenDoubleTap() { + val node = findNodeWithTag(Tag) + node.tap() + delay(DoubleTapPreparationDelayMillis) + node.doubleTap() + } + + @OptIn(ExperimentalFoundationApi::class) + private fun runSelectionContainerContextMenuTest(testBlock: UIKitInstrumentedTest.() -> Unit) = + runUIKitInstrumentedTest(params = listOf(false, true)) { newContextMenuEnabled -> + val previousValue = ComposeFoundationFlags.isNewContextMenuEnabled + ComposeFoundationFlags.isNewContextMenuEnabled = newContextMenuEnabled + try { + testBlock() + } finally { + ComposeFoundationFlags.isNewContextMenuEnabled = previousValue + } + } + + private fun UIKitInstrumentedTest.longPressAndDrag( + startTag: String, + endTag: String, + startXFraction: Float, + endXFraction: Float, + startYFraction: Float = 0.5f, + endYFraction: Float = 0.5f, + ) { + val startPoint = pointInNode(startTag, startXFraction, startYFraction) + val endPoint = pointInNode(endTag, endXFraction, endYFraction) + + val touch = touchDown(startPoint) + waitUntil("Selection loupe should appear after long press") { + findFirstDescendant { it.isLoupeView } != null + } + touch.hold() + delay(LongPressDragSettleDelayMillis) + touch.dragTo(x = endPoint.x, y = endPoint.y, duration = 0.3.seconds) + touch.up() + } + + private fun UIKitInstrumentedTest.pointInNode( + tag: String, + xFraction: Float, + yFraction: Float, + ): DpOffset { + val frame = findNodeWithTag(tag).frame!! + return DpOffset( + x = frame.left + (frame.right - frame.left) * xFraction, + y = frame.top + (frame.bottom - frame.top) * yFraction, + ) + } + + private fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { + if (available(OS.Ios to OSVersion(16))) { + findNodeWithLabel(label).tap() + } else { + findNodeWithLabel(label) + .touchDown(useNodeWindow = true) + .hold() + .also { delay(LegacyContextMenuButtonHoldDelayMillis) } + .up() + } + } + + private fun UIKitInstrumentedTest.assertNoContextMenu() { + assertNull( + firstNodeOrNull { node -> + node.element?.let { it::class.simpleName } == if (available(OS.Ios to OSVersion(16))) { + "_UIEditMenuContainerView" + } else { + "UICalloutBar" + } + }, + "Expected no SelectionContainer context menu host to be present.", + ) + } + + private fun SelectionState.selectedText(): String = + selectedTexts.joinToString(separator = "") { it.text } + + private companion object { + private const val Tag = "SelectionContainer" + private const val FirstTextTag = "SelectionContainerFirstText" + private const val SecondTextTag = "SelectionContainerSecondText" + // Gives the first tap time to settle so the next doubleTap() is treated as a new gesture. + private const val DoubleTapPreparationDelayMillis = 500L + // Lets us observe that long-pressing empty text does not create a late menu or selection. + private const val EmptyTextLongPressSettleDelayMillis = 500L + // Keeps the two manual taps close enough for UIKit to recognize them as a double tap. + private const val ManualDoubleTapIntervalDelayMillis = 50L + // Gives long-press state a brief moment to settle before starting a drag extension. + private const val LongPressDragSettleDelayMillis = 100L + // On iOS < 16, hold the menu item briefly so the separate-window press is registered. + private const val LegacyContextMenuButtonHoldDelayMillis = 100L + private val SelectableTextWidth = 160.dp + } +} From dbd694a9dfba594605c6c50b051c962755819c06 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Tue, 16 Jun 2026 21:04:35 +0200 Subject: [PATCH 2/9] remove redundant test tag --- .../ui/interaction/SelectionContainerInteractionTest.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index 39a324439e119..ba9abf17c3cac 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -183,9 +183,7 @@ class SelectionContainerInteractionTest { DisableSelection { BasicText( text = "hidden", - modifier = - Modifier.width(SelectableTextWidth) - .testTag("SelectionContainerDisabledText"), + modifier = Modifier.width(SelectableTextWidth), ) } BasicText( From a1bc4a2c91c28ac1997d8fe05493a04e7ab71eb6 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Tue, 16 Jun 2026 21:20:06 +0200 Subject: [PATCH 3/9] rename test clearer --- .../ui/interaction/SelectionContainerInteractionTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index ba9abf17c3cac..dc40be7fd7c14 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -130,7 +130,7 @@ class SelectionContainerInteractionTest { } @Test - fun testSelectionContainer_LongPressDragExtendsSelectionAcrossTextChildren() = + fun testSelectionContainer_LongPressDragExtendsSelectionAcrossMultipleBasicTexts() = runUIKitInstrumentedTest { val selectionState = SelectionState() val firstText = "accomplishment" @@ -156,7 +156,7 @@ class SelectionContainerInteractionTest { endXFraction = 0.98f, ) - waitUntil("SelectionContainer should extend selection across text children") { + waitUntil("SelectionContainer should extend selection across multiple BasicTexts") { selectionState.selectedText() == firstText + secondText } From 924b0036b8fa161a8dc7ff1c3e0559b586ab1a2a Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Tue, 16 Jun 2026 21:58:52 +0200 Subject: [PATCH 4/9] Extract tapContextMenuButton test helper --- .../SelectionContainerInteractionTest.kt | 15 +-------------- .../ui/interaction/TextFieldEditMenuTest.kt | 19 +------------------ .../compose/ui/test/UIKitInstrumentedTest.kt | 14 ++++++++++++++ 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index dc40be7fd7c14..911302856919c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.findNodeWithTagOrNull import androidx.compose.ui.test.firstNodeOrNull import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.utils.findFirstDescendant import androidx.compose.ui.test.utils.hold import androidx.compose.ui.test.utils.isLoupeView @@ -382,18 +383,6 @@ class SelectionContainerInteractionTest { ) } - private fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { - if (available(OS.Ios to OSVersion(16))) { - findNodeWithLabel(label).tap() - } else { - findNodeWithLabel(label) - .touchDown(useNodeWindow = true) - .hold() - .also { delay(LegacyContextMenuButtonHoldDelayMillis) } - .up() - } - } - private fun UIKitInstrumentedTest.assertNoContextMenu() { assertNull( firstNodeOrNull { node -> @@ -422,8 +411,6 @@ class SelectionContainerInteractionTest { private const val ManualDoubleTapIntervalDelayMillis = 50L // Gives long-press state a brief moment to settle before starting a drag extension. private const val LongPressDragSettleDelayMillis = 100L - // On iOS < 16, hold the menu item briefly so the separate-window press is registered. - private const val LegacyContextMenuButtonHoldDelayMillis = 100L private val SelectableTextWidth = 160.dp } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 3ed04829cf51b..830662c71c4e9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -51,8 +51,8 @@ import androidx.compose.ui.test.findNodeWithLabel import androidx.compose.ui.test.findNodeWithLabelOrNull import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.utils.findFirstDescendant -import androidx.compose.ui.test.utils.hold import androidx.compose.ui.test.utils.isLoupeView import androidx.compose.ui.test.utils.up import androidx.compose.ui.test.waitForContextMenu @@ -67,9 +67,6 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlinx.cinterop.ExperimentalForeignApi -import org.jetbrains.skiko.OS -import org.jetbrains.skiko.OSVersion -import org.jetbrains.skiko.available import platform.UIKit.UIPasteboard class TextFieldEditMenuTest { @@ -846,18 +843,4 @@ class TextFieldEditMenuTest { private fun UIKitInstrumentedTest.verifyFullToolbarPresent() { verifyContextMenuItemsVisible(listOf("Cut", "Copy", "Paste", "Select All")) } - - private fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { - if (available(OS.Ios to OSVersion(16))) { - findNodeWithLabel(label).tap() - } else { - // Because on iOS < 16 the context menu is shown in a separate window, - // it's not fully interactive with the default Tap action. - findNodeWithLabel(label) - .touchDown(useNodeWindow = true) - .hold() - .also { delay(100) } - .up() - } - } } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index bc6f49b3e6eb2..590566add025c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -848,3 +848,17 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } delay(500) // wait for toolbar animation } + +internal fun UIKitInstrumentedTest.tapContextMenuButton(label: String) { + if (available(OS.Ios to OSVersion(16))) { + findNodeWithLabel(label).tap() + } else { + // Because on iOS < 16 the context menu is shown in a separate window, + // it's not fully interactive with the default Tap action. + findNodeWithLabel(label) + .touchDown(useNodeWindow = true) + .hold() + .also { delay(100) } + .up() + } +} From efe8c59d0d408560203360838e70c85eb2beeb12 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Tue, 16 Jun 2026 22:37:49 +0200 Subject: [PATCH 5/9] Extract focusThenDoubleTap test helper --- .../interaction/SelectionContainerInteractionTest.kt | 11 ++--------- .../compose/ui/interaction/TextFieldEditMenuTest.kt | 4 +--- .../androidx/compose/ui/test/UIKitInstrumentedTest.kt | 7 +++++++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index 911302856919c..6f1740a5d1822 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -85,7 +85,7 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = text) - focusThenDoubleTap() + focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) waitUntil("SelectionContainer should select the word after double tap") { selectionState.selectedText() == text } @@ -267,7 +267,7 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = "copyable second") awaitNodeLaidOut(Tag) - focusThenDoubleTap() + focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) waitForContextMenu() waitUntil("SelectionContainer should create a selection before menu open") { selectionState.selectedTexts.isNotEmpty() @@ -331,13 +331,6 @@ class SelectionContainerInteractionTest { waitForContextMenu() } - private fun UIKitInstrumentedTest.focusThenDoubleTap() { - val node = findNodeWithTag(Tag) - node.tap() - delay(DoubleTapPreparationDelayMillis) - node.doubleTap() - } - @OptIn(ExperimentalFoundationApi::class) private fun runSelectionContainerContextMenuTest(testBlock: UIKitInstrumentedTest.() -> Unit) = runUIKitInstrumentedTest(params = listOf(false, true)) { newContextMenuEnabled -> diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 830662c71c4e9..0898594b09693 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -739,9 +739,7 @@ class TextFieldEditMenuTest { } private fun UIKitInstrumentedTest.openToolbar(textFieldTag: String) { - findNodeWithTag(textFieldTag).tap() - delay(500) - findNodeWithTag(textFieldTag).doubleTap() + focusThenDoubleTap(textFieldTag) waitForContextMenu() } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 590566add025c..38f6760b7eb3b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -578,6 +578,13 @@ internal class UIKitInstrumentedTest( return tap(frame.center()) } + fun focusThenDoubleTap(tag: String, delayMillis: Long = 500L) { + val node = findNodeWithTag(tag) + node.tap() + delay(delayMillis) + node.doubleTap() + } + /** * Simulates a touch-down event at the center of a given AccessibilityTestNode. */ From 0355051f11b06df57726ade0bd11e73197ca91e1 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Sat, 27 Jun 2026 13:03:58 +0200 Subject: [PATCH 6/9] Make 'focusThenDoubleTap' extension method of AccessibilityTestNode and don't pass delay argument value equal to the default one It addresses review comments * https://github.com/JetBrains/compose-multiplatform-core/pull/3113#discussion_r3450812999, * https://github.com/JetBrains/compose-multiplatform-core/pull/3113#discussion_r3450860995 --- .../ui/interaction/SelectionContainerInteractionTest.kt | 4 ++-- .../compose/ui/interaction/TextFieldEditMenuTest.kt | 2 +- .../ui/interaction/TextFieldMultiTapSelectionTest.kt | 4 ++-- .../androidx/compose/ui/test/UIKitInstrumentedTest.kt | 7 +++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index 6f1740a5d1822..f62be664369b9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -85,7 +85,7 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = text) - focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) + findNodeWithTag(Tag).focusThenDoubleTap() waitUntil("SelectionContainer should select the word after double tap") { selectionState.selectedText() == text } @@ -267,7 +267,7 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = "copyable second") awaitNodeLaidOut(Tag) - focusThenDoubleTap(Tag, delayMillis = DoubleTapPreparationDelayMillis) + findNodeWithTag(Tag).focusThenDoubleTap() waitForContextMenu() waitUntil("SelectionContainer should create a selection before menu open") { selectionState.selectedTexts.isNotEmpty() diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index 0898594b09693..e06b474ff9f7e 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -739,7 +739,7 @@ class TextFieldEditMenuTest { } private fun UIKitInstrumentedTest.openToolbar(textFieldTag: String) { - focusThenDoubleTap(textFieldTag) + findNodeWithTag(textFieldTag).focusThenDoubleTap() waitForContextMenu() } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMultiTapSelectionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMultiTapSelectionTest.kt index 369c7586d535f..faa18654f19d0 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMultiTapSelectionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldMultiTapSelectionTest.kt @@ -47,7 +47,7 @@ class TextFieldMultiTapSelectionTest { @Test fun double_tap_selects_word() = runUIKitInstrumentedTest(params = tfOptions) { textFieldOption -> textFieldOption.setup(this, MULTI_WORD_TEXT, TAG) - focusThenDoubleTap(TAG) + findNodeWithTag(TAG).focusThenDoubleTap() assertFalse(textFieldOption.selection.collapsed, "[${textFieldOption.name}] Expected a word to be selected after double tap") assertTrue( textFieldOption.selection.length < MULTI_WORD_TEXT.length, @@ -68,7 +68,7 @@ class TextFieldMultiTapSelectionTest { @Test fun multitap_does_not_show_magnifier() = runUIKitInstrumentedTest(params = tfOptions) { textFieldOption -> textFieldOption.setup(this, MULTI_WORD_TEXT, TAG) - focusThenDoubleTap(TAG) // double tap is enough + findNodeWithTag(TAG).focusThenDoubleTap() // double tap is enough delay(200) assertEquals( findFirstDescendant { it.isLoupeView }, diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 38f6760b7eb3b..a22820cd7b910 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -578,11 +578,10 @@ internal class UIKitInstrumentedTest( return tap(frame.center()) } - fun focusThenDoubleTap(tag: String, delayMillis: Long = 500L) { - val node = findNodeWithTag(tag) - node.tap() + fun AccessibilityTestNode.focusThenDoubleTap(delayMillis: Long = 500L) { + tap() delay(delayMillis) - node.doubleTap() + doubleTap() } /** From 93bd21f6b9262024fa0ba4a2f47e875e63c127be Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Sat, 27 Jun 2026 13:07:39 +0200 Subject: [PATCH 7/9] Rename longPressAndAwaitContextMenu -> longPressNodeWithTagAndAwaitContextMenu It addresses review comment https://github.com/JetBrains/compose-multiplatform-core/pull/3113#discussion_r3450822846. --- .../ui/interaction/TextFieldEditMenuTest.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt index e06b474ff9f7e..4f0077056b586 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuTest.kt @@ -231,7 +231,7 @@ class TextFieldEditMenuTest { } // A long press positions the cursor and, on release, reveals the context menu. - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") waitForContextMenu() findNodeWithLabel("Paste").assertVisibleInContainer() @@ -243,7 +243,7 @@ class TextFieldEditMenuTest { } // A tap again brings the context menu back. - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") findNodeWithLabel("Paste").assertVisibleInContainer() } @@ -265,7 +265,7 @@ class TextFieldEditMenuTest { } // A long press positions the cursor and, on release, reveals the context menu. - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") findNodeWithLabel("Paste").assertVisibleInContainer() // A short tap elsewhere dismisses the context menu. @@ -275,7 +275,7 @@ class TextFieldEditMenuTest { } // A long press again brings the context menu back. - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") findNodeWithLabel("Paste").assertVisibleInContainer() } @@ -289,7 +289,7 @@ class TextFieldEditMenuTest { readOnly = false ) - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") verifyContextMenuItemsVisible( labels = if (newContextMenu) { listOf("Paste", "Select All") @@ -327,7 +327,7 @@ class TextFieldEditMenuTest { readOnly = false ) - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") verifyContextMenuItemsVisible( labels = if (newContextMenu) { listOf("Select All") @@ -430,7 +430,7 @@ class TextFieldEditMenuTest { UIPasteboard.generalPasteboard().string = "Paste text" val isFullySelected = setContentAndGetIsFullySelected() - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") tapContextMenuButton("Select All") waitUntil("Text field should be fully selected") { isFullySelected() @@ -458,7 +458,7 @@ class TextFieldEditMenuTest { readOnly = true ) - longPressAndAwaitContextMenu("TextField") + longPressNodeWithTagAndAwaitContextMenu("TextField") verifyContextMenuItemsVisible(labels = listOf("Select All")) verifyContextMenuItemsHidden(labels = listOf("Cut", "Copy", "Paste", "Select")) } @@ -748,7 +748,7 @@ class TextFieldEditMenuTest { .testTag("TextField") .focusRequester(focusRequester) - private fun UIKitInstrumentedTest.longPressAndAwaitContextMenu(textFieldTag: String) { + private fun UIKitInstrumentedTest.longPressNodeWithTagAndAwaitContextMenu(textFieldTag: String) { val touch = findNodeWithTag(textFieldTag).touchDown() waitUntil { findFirstDescendant { it.isLoupeView } != null From 91545a037f41299a4cc6592a689840c4166bfb8e Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Sat, 27 Jun 2026 13:47:07 +0200 Subject: [PATCH 8/9] Redo few methods as extensions of AccessibilityTestNode instead of UIKitInstrumentedTest It addresses review comment https://github.com/JetBrains/compose-multiplatform-core/pull/3113#discussion_r3450895087. --- .../SelectionContainerInteractionTest.kt | 42 ++++--------------- .../compose/ui/test/UIKitInstrumentedTest.kt | 34 +++++++++++++++ 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index f62be664369b9..66af9472f58b6 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -45,7 +45,6 @@ import androidx.compose.ui.test.utils.isLoupeView import androidx.compose.ui.test.utils.up import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +65,7 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = text) - longPressAndReleaseAfterLoupe(Tag) + findNodeWithTag(Tag).longPressAndReleaseAfterLoupe() waitUntil("SelectionContainer should select the word after long press") { selectionState.selectedText() == text @@ -223,7 +222,10 @@ class SelectionContainerInteractionTest { setSelectionContainerContent(state = selectionState, text = text) awaitNodeLaidOut(Tag) - openToolbarForLeadingWord(Tag) + findNodeWithTag(Tag).openToolbarForLeadingWord( + DoubleTapPreparationDelayMillis, + ManualDoubleTapIntervalDelayMillis + ) waitUntil("SelectionContainer should create the expected word selection before Copy") { selectionState.selectedText() == firstWord } @@ -307,30 +309,12 @@ class SelectionContainerInteractionTest { } } - private fun UIKitInstrumentedTest.longPressAndReleaseAfterLoupe(tag: String) { - val touch = findNodeWithTag(tag).touchDown() - waitUntil("Selection loupe should appear after long press") { - findFirstDescendant { it.isLoupeView } != null - } - touch.up() - } - private fun UIKitInstrumentedTest.awaitNodeLaidOut(tag: String) { waitUntil("Node with tag $tag should be laid out") { findNodeWithTagOrNull(tag)?.frame != null } } - private fun UIKitInstrumentedTest.openToolbarForLeadingWord(tag: String) { - findNodeWithTag(tag).tap() - delay(DoubleTapPreparationDelayMillis) - val tapPoint = pointInNode(tag, xFraction = 0.1f, yFraction = 0.5f) - tap(tapPoint) - delay(ManualDoubleTapIntervalDelayMillis) - tap(tapPoint) - waitForContextMenu() - } - @OptIn(ExperimentalFoundationApi::class) private fun runSelectionContainerContextMenuTest(testBlock: UIKitInstrumentedTest.() -> Unit) = runUIKitInstrumentedTest(params = listOf(false, true)) { newContextMenuEnabled -> @@ -351,8 +335,8 @@ class SelectionContainerInteractionTest { startYFraction: Float = 0.5f, endYFraction: Float = 0.5f, ) { - val startPoint = pointInNode(startTag, startXFraction, startYFraction) - val endPoint = pointInNode(endTag, endXFraction, endYFraction) + val startPoint = findNodeWithTag(startTag).pointInNode(startXFraction, startYFraction) + val endPoint = findNodeWithTag(endTag).pointInNode(endXFraction, endYFraction) val touch = touchDown(startPoint) waitUntil("Selection loupe should appear after long press") { @@ -364,18 +348,6 @@ class SelectionContainerInteractionTest { touch.up() } - private fun UIKitInstrumentedTest.pointInNode( - tag: String, - xFraction: Float, - yFraction: Float, - ): DpOffset { - val frame = findNodeWithTag(tag).frame!! - return DpOffset( - x = frame.left + (frame.right - frame.left) * xFraction, - y = frame.top + (frame.bottom - frame.top) * yFraction, - ) - } - private fun UIKitInstrumentedTest.assertNoContextMenu() { assertNull( firstNodeOrNull { node -> diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index a22820cd7b910..edc0b36307161 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -27,8 +27,10 @@ import androidx.compose.ui.test.utils.beginKeyPress import androidx.compose.ui.test.utils.beginModifierKeyPress import androidx.compose.ui.test.utils.beginPress import androidx.compose.ui.test.utils.center +import androidx.compose.ui.test.utils.findFirstDescendant import androidx.compose.ui.test.utils.getTouchesEvent import androidx.compose.ui.test.utils.hold +import androidx.compose.ui.test.utils.isLoupeView import androidx.compose.ui.test.utils.leftCenter import androidx.compose.ui.test.utils.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow @@ -593,6 +595,38 @@ internal class UIKitInstrumentedTest( return touchDown(frame.center(), window) } + fun AccessibilityTestNode.longPressAndReleaseAfterLoupe() { + val touch = touchDown() + waitUntil("Selection loupe should appear after long press") { + findFirstDescendant { it.isLoupeView } != null + } + touch.up() + } + + fun AccessibilityTestNode.openToolbarForLeadingWord( + doubleTapPreparationDelay: Long, + manualDoubleTapIntervalDelay: Long + ) { + tap() + delay(doubleTapPreparationDelay) + val tapPoint = pointInNode(xFraction = 0.1f, yFraction = 0.5f) + tap(tapPoint) + delay(manualDoubleTapIntervalDelay) + tap(tapPoint) + waitForContextMenu() + } + + fun AccessibilityTestNode.pointInNode( + xFraction: Float, + yFraction: Float, + ): DpOffset { + val frame = frame!! + return DpOffset( + x = frame.left + (frame.right - frame.left) * xFraction, + y = frame.top + (frame.bottom - frame.top) * yFraction, + ) + } + /** * Simulates a drag gesture on the screen, moving the touch from its current location to a specified position * over a given duration. From 549c88e2267a4c84ec1d2d396b6e208dceccdc0d Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Sat, 27 Jun 2026 13:51:22 +0200 Subject: [PATCH 9/9] UIKitInstrumentedTest.setSelectionContainerContent: + missing safeDrawingPadding() This change was suggested in the review comment https://github.com/JetBrains/compose-multiplatform-core/pull/3113#discussion_r3450878819. --- .../ui/interaction/SelectionContainerInteractionTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt index 66af9472f58b6..23468aae8120b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/SelectionContainerInteractionTest.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.selection.DisableSelection @@ -288,7 +289,7 @@ class SelectionContainerInteractionTest { content: @Composable () -> Unit, ) { setContent { - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { SelectionContainer( state = state, modifier = Modifier.align(Alignment.Center).testTag(Tag),