From a80e9f5eacfa3b0072c75c0cf7f2ee2f3afaba02 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 17 Jun 2026 00:03:32 +0200 Subject: [PATCH 1/5] + TextFieldEditMenuClipboardTest --- .../TextFieldEditMenuClipboardTest.kt | 175 ++++++++++++++++++ .../ui/interaction/TextFieldEditMenuTest.kt | 39 +--- .../compose/ui/test/UIKitInstrumentedTest.kt | 34 +++- 3 files changed, 212 insertions(+), 36 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt new file mode 100644 index 0000000000000..ca479beb577ac --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt @@ -0,0 +1,175 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.assertVisibleInContainer +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.waitForContextMenu +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.DpOffset +import kotlin.test.Test +import kotlin.test.assertEquals +import platform.UIKit.UIPasteboard + +class TextFieldEditMenuClipboardTest { + + @Test + fun testTextFieldContextMenu_CopyThenPasteReplacesWord() = + runClipboardMenuTest { textFieldKind -> + UIPasteboard.generalPasteboard().string = "Clipboard sentinel" + val text = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "copyable target", + ) + + openToolbarForWord(tag = TextFieldTag, xFraction = 0.2f) + findNodeWithLabel("Copy").assertVisibleInContainer() + tapContextMenuButton("Copy") + + waitUntil("Pasteboard should contain copied text") { + UIPasteboard.generalPasteboard().string == "copyable" + } + + waitUntil("Copy menu should close before opening Paste menu") { + findNodeWithLabelOrNull("Copy") == null + } + + openToolbarForWord(tag = TextFieldTag, xFraction = 0.8f) + findNodeWithLabel("Paste").assertVisibleInContainer() + tapContextMenuButton("Paste") + + waitUntil("Text field should replace selected text with pasteboard text") { + text() == "copyable copyable" + } + assertEquals("copyable", UIPasteboard.generalPasteboard().string) + } + + @OptIn(ExperimentalFoundationApi::class) + private fun runClipboardMenuTest( + testBlock: UIKitInstrumentedTest.(EditableTextFieldKind) -> Unit + ) { + for (newContextMenuEnabled in listOf(false, true)) { + for (textFieldKind in EditableTextFieldKind.entries) { + runUIKitInstrumentedTest { + val previousValue = ComposeFoundationFlags.isNewContextMenuEnabled + ComposeFoundationFlags.isNewContextMenuEnabled = newContextMenuEnabled + try { + testBlock(textFieldKind) + } finally { + ComposeFoundationFlags.isNewContextMenuEnabled = previousValue + } + } + } + } + } + + private fun UIKitInstrumentedTest.setTextFieldContent( + textFieldKind: EditableTextFieldKind, + initialText: String, + ): () -> String { + var text = { initialText } + setContent { + val focusRequester = remember { FocusRequester() } + Column(modifier = Modifier.safeDrawingPadding()) { + when (textFieldKind) { + EditableTextFieldKind.BasicTextField -> { + val textFieldValue = remember { + mutableStateOf(TextFieldValue(initialText)) + } + text = { textFieldValue.value.text } + BasicTextField( + value = textFieldValue.value, + onValueChange = { textFieldValue.value = it }, + modifier = textFieldModifier(focusRequester), + ) + } + + EditableTextFieldKind.BasicTextField2 -> { + val textFieldState = remember { + TextFieldState(initialText) + } + text = { textFieldState.text.toString() } + BasicTextField( + state = textFieldState, + modifier = textFieldModifier(focusRequester), + ) + } + } + } + LaunchedEffect(focusRequester) { + focusRequester.requestFocus() + } + } + return text + } + + private fun textFieldModifier(focusRequester: FocusRequester): Modifier = + Modifier + .testTag(TextFieldTag) + .focusRequester(focusRequester) + + private fun UIKitInstrumentedTest.openToolbarForWord(tag: String, xFraction: Float) { + findNodeWithTag(tag).tap() + delay(DoubleTapPreparationDelayMillis) + val tapPoint = pointInNode(tag, xFraction = xFraction, yFraction = 0.5f) + tap(tapPoint) + delay(ManualDoubleTapIntervalDelayMillis) + tap(tapPoint) + waitForContextMenu() + } + + 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 enum class EditableTextFieldKind { + BasicTextField, + BasicTextField2, + } + + private companion object { + private const val TextFieldTag = "TextField" + private const val DoubleTapPreparationDelayMillis = 500L + private const val ManualDoubleTapIntervalDelayMillis = 50L + } +} 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 7cc650a66272f..115d9366d97b6 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 @@ -50,10 +50,9 @@ import androidx.compose.ui.test.assertVisibleInContainer import androidx.compose.ui.test.findNodeWithLabel import androidx.compose.ui.test.findNodeWithLabelOrNull import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.longPressAndAwaitContextMenu 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.tapContextMenuButton import androidx.compose.ui.test.utils.up import androidx.compose.ui.test.waitForContextMenu import androidx.compose.ui.text.TextRange @@ -62,14 +61,9 @@ import androidx.compose.ui.unit.dp import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotNull import kotlin.test.assertTrue -import kotlin.test.fail 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 { @@ -551,18 +545,7 @@ class TextFieldEditMenuTest { } private fun UIKitInstrumentedTest.openToolbar(textFieldTag: String) { - findNodeWithTag(textFieldTag).tap() - delay(500) - findNodeWithTag(textFieldTag).doubleTap() - waitForContextMenu() - } - - private fun UIKitInstrumentedTest.longPressAndAwaitContextMenu(textFieldTag: String) { - val touch = findNodeWithTag(textFieldTag).touchDown() - waitUntil { - findFirstDescendant { it.isLoupeView } != null - } - touch.up() + focusThenDoubleTap(textFieldTag) waitForContextMenu() } @@ -602,18 +585,4 @@ class TextFieldEditMenuTest { assertTrue(it.isAccessibilityElement ?: false) } } - - 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() - } - } -} \ No newline at end of file +} 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 0646421cf2e17..aa9d989c3b75e 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.mouseDown import androidx.compose.ui.test.utils.moveToLocationOnWindow import androidx.compose.ui.test.utils.release @@ -511,6 +513,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. */ @@ -756,4 +765,27 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} \ No newline at end of file +} + +internal fun UIKitInstrumentedTest.longPressAndAwaitContextMenu(tag: String) { + val touch = findNodeWithTag(tag).touchDown() + waitUntil { + findFirstDescendant { it.isLoupeView } != null + } + touch.up() + waitForContextMenu() +} + +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 0cc07dc8a28087243f6789c6eff941396090bacc Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 17 Jun 2026 16:34:35 +0200 Subject: [PATCH 2/5] + TextFieldEditMenuClipboardTest --- .../TextFieldEditMenuClipboardTest.kt | 285 ++++++++++++++++-- 1 file changed, 262 insertions(+), 23 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt index ca479beb577ac..26ffb84aea86a 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt @@ -30,50 +30,248 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.UIKitInstrumentedTest -import androidx.compose.ui.test.assertVisibleInContainer -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.waitForContextMenu +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.DpOffset import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import platform.UIKit.UIPasteboard class TextFieldEditMenuClipboardTest { @Test - fun testTextFieldContextMenu_CopyThenPasteReplacesWord() = + fun testTextFieldContextMenu_CopyThenPasteReplacesSelectedWord() = runClipboardMenuTest { textFieldKind -> - UIPasteboard.generalPasteboard().string = "Clipboard sentinel" - val text = setTextFieldContent( + val copiedWord = "copyable" + val initialText = "$copiedWord $TargetWord" + val expectedText = "$copiedWord $copiedWord" + val content = setTextFieldContent( textFieldKind = textFieldKind, - initialText = "copyable target", + initialText = initialText, ) + UIPasteboard.generalPasteboard().string = null - openToolbarForWord(tag = TextFieldTag, xFraction = 0.2f) - findNodeWithLabel("Copy").assertVisibleInContainer() + openToolbarForWord(xFraction = FirstWordPosition) tapContextMenuButton("Copy") - waitUntil("Pasteboard should contain copied text") { - UIPasteboard.generalPasteboard().string == "copyable" + waitUntil("Pasteboard should contain copied word") { + UIPasteboard.generalPasteboard().string == copiedWord } - waitUntil("Copy menu should close before opening Paste menu") { - findNodeWithLabelOrNull("Copy") == null + waitUntilContextMenuClosed("Copy") + + openToolbarForWord(xFraction = SecondWordPosition) + tapContextMenuButton("Paste") + + waitUntil("Text field should replace target word with copied word") { + content.text() == expectedText + } + assertEquals(copiedWord, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_CutThenPasteReplacesSelectedWord() = + runClipboardMenuTest { textFieldKind -> + val cutWord = "cut" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "left $cutWord $TargetWord", + ) + UIPasteboard.generalPasteboard().string = null + + openToolbarForWord(xFraction = MiddleWordPosition) + tapContextMenuButton("Cut") + + waitUntil("Text field should remove cut word and copy it to pasteboard") { + content.text() == "left $TargetWord" && + UIPasteboard.generalPasteboard().string == cutWord + } + + waitUntilContextMenuClosed("Cut") + + openToolbarForWord(xFraction = LastWordPosition) + tapContextMenuButton("Paste") + + waitUntil("Text field should paste cut word over selected word") { + content.text() == "left $cutWord" + } + assertEquals(cutWord, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_PasteOverSelectionReplacesSelectedText() = + runClipboardMenuTest { textFieldKind -> + val pastedWord = "Kotlin" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "Hello $TargetWord", + ) + UIPasteboard.generalPasteboard().string = pastedWord + + openToolbarForWord(xFraction = SecondWordPosition) + tapContextMenuButton("Paste") + + waitUntil("Text field should replace selected word with pasteboard text") { + content.text() == "Hello $pastedWord" + } + assertEquals(pastedWord, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_PasteOverSelectionPreservesMixedLatinJapaneseText() = + runClipboardMenuTest { textFieldKind -> + val mixedText = "Tokyo\u6771\u4EAC" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "Hello $TargetWord", + ) + UIPasteboard.generalPasteboard().string = mixedText + + openToolbarForWord(xFraction = SecondWordPosition) + tapContextMenuButton("Paste") + + waitUntil("Text field should paste exact mixed Latin/Japanese text") { + content.text() == "Hello $mixedText" + } + assertEquals(mixedText, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_PasteAtCollapsedCursorInsertsText() = + runClipboardMenuTest { textFieldKind -> + val pastedWord = "Kotlin" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "Hello ", + ) + UIPasteboard.generalPasteboard().string = pastedWord + + openToolbarForCollapsedCursor(xFraction = TextEndPosition) + tapContextMenuButton("Paste") + + waitUntil("Text field should insert pasteboard text at cursor") { + content.text() == "Hello $pastedWord" + } + assertEquals(pastedWord, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_SelectAllSelectsMixedLatinJapaneseText() = + runClipboardMenuTest { textFieldKind -> + val mixedText = "Tokyo\u6771\u4EAC" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = mixedText, + ) + UIPasteboard.generalPasteboard().string = ClipboardSentinel + + openToolbarForWord(xFraction = FirstWordPosition) + tapContextMenuButton("Select All") + + waitUntil("Text field should select the whole mixed Latin/Japanese text") { + content.selection() == TextRange(0, mixedText.length) + } + assertEquals(ClipboardSentinel, UIPasteboard.generalPasteboard().string) + } + + @Test + fun testTextFieldContextMenu_SelectAllThenCopyCopiesWholeText() = + runClipboardMenuTest { textFieldKind -> + val text = "Hello $TargetWord" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = text, + ) + UIPasteboard.generalPasteboard().string = null + + openToolbarForWord(xFraction = FirstWordPosition) + tapContextMenuButton("Select All") + + waitUntil("Text field should select all text") { + content.selection() == TextRange(0, text.length) + } + + tapContextMenuButton("Copy") + + waitUntil("Pasteboard should contain all selected text") { + UIPasteboard.generalPasteboard().string == text + } + assertEquals(text, content.text()) + } + + @Test + fun testTextFieldContextMenu_SelectAllThenCutRemovesWholeText() = + runClipboardMenuTest { textFieldKind -> + val text = "Hello $TargetWord" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = text, + ) + UIPasteboard.generalPasteboard().string = null + + openToolbarForWord(xFraction = FirstWordPosition) + tapContextMenuButton("Select All") + + waitUntil("Text field should select all text") { + content.selection() == TextRange(0, text.length) + } + + tapContextMenuButton("Cut") + + waitUntil("Text field should remove all selected text and copy it to pasteboard") { + content.text().isEmpty() && + UIPasteboard.generalPasteboard().string == text + } + } + + @Test + fun testTextFieldContextMenu_PasteIsNotShownWhenClipboardIsEmpty() = + runClipboardMenuTest { textFieldKind -> + UIPasteboard.generalPasteboard().string = null + setTextFieldContent( + textFieldKind = textFieldKind, + initialText = "Hello $TargetWord", + ) + + openToolbarForWord(xFraction = SecondWordPosition) + + assertNull(findNodeWithLabelOrNull("Paste")) + } + + @Test + fun testTextFieldContextMenu_CopyPasteArabicRtlWord() = + runClipboardMenuTest { textFieldKind -> + val copiedWord = "\u0645\u0631\u062D\u0628\u0627" + val initialText = "start $copiedWord $TargetWord" + val expectedText = "start $copiedWord $copiedWord" + val content = setTextFieldContent( + textFieldKind = textFieldKind, + initialText = initialText, + ) + UIPasteboard.generalPasteboard().string = null + + openToolbarForWord(xFraction = MiddleWordPosition) + tapContextMenuButton("Copy") + + waitUntil("Pasteboard should contain copied Arabic word") { + UIPasteboard.generalPasteboard().string == copiedWord } - openToolbarForWord(tag = TextFieldTag, xFraction = 0.8f) - findNodeWithLabel("Paste").assertVisibleInContainer() + waitUntilContextMenuClosed("Copy") + + openToolbarForWord(xFraction = LastWordPosition) tapContextMenuButton("Paste") - waitUntil("Text field should replace selected text with pasteboard text") { - text() == "copyable copyable" + waitUntil("Text field should replace target word with copied Arabic word") { + content.text() == expectedText } - assertEquals("copyable", UIPasteboard.generalPasteboard().string) + assertEquals(copiedWord, UIPasteboard.generalPasteboard().string) } @OptIn(ExperimentalFoundationApi::class) @@ -98,8 +296,9 @@ class TextFieldEditMenuClipboardTest { private fun UIKitInstrumentedTest.setTextFieldContent( textFieldKind: EditableTextFieldKind, initialText: String, - ): () -> String { + ): TextFieldContent { var text = { initialText } + var selection = { TextRange.Zero } setContent { val focusRequester = remember { FocusRequester() } Column(modifier = Modifier.safeDrawingPadding()) { @@ -109,6 +308,7 @@ class TextFieldEditMenuClipboardTest { mutableStateOf(TextFieldValue(initialText)) } text = { textFieldValue.value.text } + selection = { textFieldValue.value.selection } BasicTextField( value = textFieldValue.value, onValueChange = { textFieldValue.value = it }, @@ -121,6 +321,7 @@ class TextFieldEditMenuClipboardTest { TextFieldState(initialText) } text = { textFieldState.text.toString() } + selection = { textFieldState.selection } BasicTextField( state = textFieldState, modifier = textFieldModifier(focusRequester), @@ -132,7 +333,10 @@ class TextFieldEditMenuClipboardTest { focusRequester.requestFocus() } } - return text + return TextFieldContent( + text = text, + selection = selection, + ) } private fun textFieldModifier(focusRequester: FocusRequester): Modifier = @@ -140,16 +344,28 @@ class TextFieldEditMenuClipboardTest { .testTag(TextFieldTag) .focusRequester(focusRequester) - private fun UIKitInstrumentedTest.openToolbarForWord(tag: String, xFraction: Float) { - findNodeWithTag(tag).tap() + private fun UIKitInstrumentedTest.openToolbarForWord(xFraction: Float) { + findNodeWithTag(TextFieldTag).tap() delay(DoubleTapPreparationDelayMillis) - val tapPoint = pointInNode(tag, xFraction = xFraction, yFraction = 0.5f) + doubleTapTextField(xFraction = xFraction) + waitForContextMenu() + } + + private fun UIKitInstrumentedTest.openToolbarForCollapsedCursor(xFraction: Float) { + val tapPoint = pointInNode(TextFieldTag, xFraction = xFraction, yFraction = 0.5f) tap(tapPoint) - delay(ManualDoubleTapIntervalDelayMillis) + delay(LongPressPreparationDelayMillis) tap(tapPoint) waitForContextMenu() } + private fun UIKitInstrumentedTest.doubleTapTextField(xFraction: Float) { + val tapPoint = pointInNode(TextFieldTag, xFraction = xFraction, yFraction = 0.5f) + tap(tapPoint) + delay(ManualDoubleTapIntervalDelayMillis) + tap(tapPoint) + } + private fun UIKitInstrumentedTest.pointInNode( tag: String, xFraction: Float, @@ -162,6 +378,18 @@ class TextFieldEditMenuClipboardTest { ) } + private fun UIKitInstrumentedTest.waitUntilContextMenuClosed(label: String) { + waitUntil("$label menu should close before opening another menu") { + findNodeWithLabelOrNull(label) == null + } + delay(ContextMenuDismissAnimationDelayMillis) + } + + private data class TextFieldContent( + val text: () -> String, + val selection: () -> TextRange, + ) + private enum class EditableTextFieldKind { BasicTextField, BasicTextField2, @@ -169,7 +397,18 @@ class TextFieldEditMenuClipboardTest { private companion object { private const val TextFieldTag = "TextField" + private const val ClipboardSentinel = "Clipboard sentinel" + private const val TargetWord = "target" + + private const val FirstWordPosition = 0.2f + private const val SecondWordPosition = 0.8f + private const val MiddleWordPosition = 0.43f + private const val LastWordPosition = 0.75f + private const val TextEndPosition = 0.95f + private const val DoubleTapPreparationDelayMillis = 500L private const val ManualDoubleTapIntervalDelayMillis = 50L + private const val LongPressPreparationDelayMillis = 500L + private const val ContextMenuDismissAnimationDelayMillis = 150L } } From f30bafbdc5d5ca3a172b00aa6cabb220bb0ff3a2 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 17 Jun 2026 16:50:20 +0200 Subject: [PATCH 3/5] + TextFieldEditMenuClipboardTest --- .../TextFieldEditMenuClipboardTest.kt | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt index 26ffb84aea86a..0e552a2b0ea3c 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt @@ -60,10 +60,6 @@ class TextFieldEditMenuClipboardTest { openToolbarForWord(xFraction = FirstWordPosition) tapContextMenuButton("Copy") - waitUntil("Pasteboard should contain copied word") { - UIPasteboard.generalPasteboard().string == copiedWord - } - waitUntilContextMenuClosed("Copy") openToolbarForWord(xFraction = SecondWordPosition) @@ -88,9 +84,8 @@ class TextFieldEditMenuClipboardTest { openToolbarForWord(xFraction = MiddleWordPosition) tapContextMenuButton("Cut") - waitUntil("Text field should remove cut word and copy it to pasteboard") { - content.text() == "left $TargetWord" && - UIPasteboard.generalPasteboard().string == cutWord + waitUntil("Text field should remove cut word") { + content.text() == "left $TargetWord" } waitUntilContextMenuClosed("Cut") @@ -199,9 +194,7 @@ class TextFieldEditMenuClipboardTest { tapContextMenuButton("Copy") - waitUntil("Pasteboard should contain all selected text") { - UIPasteboard.generalPasteboard().string == text - } + assertEquals(text, UIPasteboard.generalPasteboard().string) assertEquals(text, content.text()) } @@ -224,10 +217,10 @@ class TextFieldEditMenuClipboardTest { tapContextMenuButton("Cut") - waitUntil("Text field should remove all selected text and copy it to pasteboard") { - content.text().isEmpty() && - UIPasteboard.generalPasteboard().string == text + waitUntil("Text field should remove all selected text") { + content.text().isEmpty() } + assertEquals(text, UIPasteboard.generalPasteboard().string) } @Test @@ -259,10 +252,6 @@ class TextFieldEditMenuClipboardTest { openToolbarForWord(xFraction = MiddleWordPosition) tapContextMenuButton("Copy") - waitUntil("Pasteboard should contain copied Arabic word") { - UIPasteboard.generalPasteboard().string == copiedWord - } - waitUntilContextMenuClosed("Copy") openToolbarForWord(xFraction = LastWordPosition) From 03725fda7baef05bf56b5dc35c99b286d5c99e53 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 17 Jun 2026 17:32:17 +0200 Subject: [PATCH 4/5] + TextFieldEditMenuClipboardTest --- .../TextFieldEditMenuClipboardTest.kt | 175 ++++++------------ 1 file changed, 53 insertions(+), 122 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt index 0e552a2b0ea3c..b3a90e01fd7ff 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,12 +36,10 @@ import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.waitForContextMenu -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.DpOffset import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull import platform.UIKit.UIPasteboard class TextFieldEditMenuClipboardTest { @@ -119,20 +118,27 @@ class TextFieldEditMenuClipboardTest { } @Test - fun testTextFieldContextMenu_PasteOverSelectionPreservesMixedLatinJapaneseText() = + fun testTextFieldContextMenu_CopyPasteMixedLatinJapaneseText() = runClipboardMenuTest { textFieldKind -> val mixedText = "Tokyo\u6771\u4EAC" + val initialText = "$mixedText $TargetWord" + val expectedText = "$mixedText $mixedText" val content = setTextFieldContent( textFieldKind = textFieldKind, - initialText = "Hello $TargetWord", + initialText = initialText, ) - UIPasteboard.generalPasteboard().string = mixedText + UIPasteboard.generalPasteboard().string = null + + openToolbarForWord(xFraction = FirstWordPosition) + tapContextMenuButton("Copy") + + waitUntilContextMenuClosed("Copy") openToolbarForWord(xFraction = SecondWordPosition) tapContextMenuButton("Paste") - waitUntil("Text field should paste exact mixed Latin/Japanese text") { - content.text() == "Hello $mixedText" + waitUntil("Text field should replace target word with mixed Latin/Japanese text") { + content.text() == expectedText } assertEquals(mixedText, UIPasteboard.generalPasteboard().string) } @@ -156,87 +162,6 @@ class TextFieldEditMenuClipboardTest { assertEquals(pastedWord, UIPasteboard.generalPasteboard().string) } - @Test - fun testTextFieldContextMenu_SelectAllSelectsMixedLatinJapaneseText() = - runClipboardMenuTest { textFieldKind -> - val mixedText = "Tokyo\u6771\u4EAC" - val content = setTextFieldContent( - textFieldKind = textFieldKind, - initialText = mixedText, - ) - UIPasteboard.generalPasteboard().string = ClipboardSentinel - - openToolbarForWord(xFraction = FirstWordPosition) - tapContextMenuButton("Select All") - - waitUntil("Text field should select the whole mixed Latin/Japanese text") { - content.selection() == TextRange(0, mixedText.length) - } - assertEquals(ClipboardSentinel, UIPasteboard.generalPasteboard().string) - } - - @Test - fun testTextFieldContextMenu_SelectAllThenCopyCopiesWholeText() = - runClipboardMenuTest { textFieldKind -> - val text = "Hello $TargetWord" - val content = setTextFieldContent( - textFieldKind = textFieldKind, - initialText = text, - ) - UIPasteboard.generalPasteboard().string = null - - openToolbarForWord(xFraction = FirstWordPosition) - tapContextMenuButton("Select All") - - waitUntil("Text field should select all text") { - content.selection() == TextRange(0, text.length) - } - - tapContextMenuButton("Copy") - - assertEquals(text, UIPasteboard.generalPasteboard().string) - assertEquals(text, content.text()) - } - - @Test - fun testTextFieldContextMenu_SelectAllThenCutRemovesWholeText() = - runClipboardMenuTest { textFieldKind -> - val text = "Hello $TargetWord" - val content = setTextFieldContent( - textFieldKind = textFieldKind, - initialText = text, - ) - UIPasteboard.generalPasteboard().string = null - - openToolbarForWord(xFraction = FirstWordPosition) - tapContextMenuButton("Select All") - - waitUntil("Text field should select all text") { - content.selection() == TextRange(0, text.length) - } - - tapContextMenuButton("Cut") - - waitUntil("Text field should remove all selected text") { - content.text().isEmpty() - } - assertEquals(text, UIPasteboard.generalPasteboard().string) - } - - @Test - fun testTextFieldContextMenu_PasteIsNotShownWhenClipboardIsEmpty() = - runClipboardMenuTest { textFieldKind -> - UIPasteboard.generalPasteboard().string = null - setTextFieldContent( - textFieldKind = textFieldKind, - initialText = "Hello $TargetWord", - ) - - openToolbarForWord(xFraction = SecondWordPosition) - - assertNull(findNodeWithLabelOrNull("Paste")) - } - @Test fun testTextFieldContextMenu_CopyPasteArabicRtlWord() = runClipboardMenuTest { textFieldKind -> @@ -285,47 +210,55 @@ class TextFieldEditMenuClipboardTest { private fun UIKitInstrumentedTest.setTextFieldContent( textFieldKind: EditableTextFieldKind, initialText: String, + ): TextFieldContent = + when (textFieldKind) { + EditableTextFieldKind.BasicTextField -> setBasicTextFieldContent(initialText) + EditableTextFieldKind.BasicTextField2 -> setBasicTextField2Content(initialText) + } + + private fun UIKitInstrumentedTest.setBasicTextFieldContent( + initialText: String, ): TextFieldContent { - var text = { initialText } - var selection = { TextRange.Zero } + val textFieldValue = mutableStateOf(TextFieldValue(initialText)) + setTextFieldContent { focusRequester -> + BasicTextField( + value = textFieldValue.value, + onValueChange = { textFieldValue.value = it }, + modifier = textFieldModifier(focusRequester), + ) + } + return TextFieldContent( + text = { textFieldValue.value.text }, + ) + } + + private fun UIKitInstrumentedTest.setBasicTextField2Content( + initialText: String, + ): TextFieldContent { + val textFieldState = TextFieldState(initialText) + setTextFieldContent { focusRequester -> + BasicTextField( + state = textFieldState, + modifier = textFieldModifier(focusRequester), + ) + } + return TextFieldContent( + text = { textFieldState.text.toString() }, + ) + } + + private fun UIKitInstrumentedTest.setTextFieldContent( + content: @Composable (FocusRequester) -> Unit, + ) { setContent { val focusRequester = remember { FocusRequester() } Column(modifier = Modifier.safeDrawingPadding()) { - when (textFieldKind) { - EditableTextFieldKind.BasicTextField -> { - val textFieldValue = remember { - mutableStateOf(TextFieldValue(initialText)) - } - text = { textFieldValue.value.text } - selection = { textFieldValue.value.selection } - BasicTextField( - value = textFieldValue.value, - onValueChange = { textFieldValue.value = it }, - modifier = textFieldModifier(focusRequester), - ) - } - - EditableTextFieldKind.BasicTextField2 -> { - val textFieldState = remember { - TextFieldState(initialText) - } - text = { textFieldState.text.toString() } - selection = { textFieldState.selection } - BasicTextField( - state = textFieldState, - modifier = textFieldModifier(focusRequester), - ) - } - } + content(focusRequester) } LaunchedEffect(focusRequester) { focusRequester.requestFocus() } } - return TextFieldContent( - text = text, - selection = selection, - ) } private fun textFieldModifier(focusRequester: FocusRequester): Modifier = @@ -376,7 +309,6 @@ class TextFieldEditMenuClipboardTest { private data class TextFieldContent( val text: () -> String, - val selection: () -> TextRange, ) private enum class EditableTextFieldKind { @@ -386,7 +318,6 @@ class TextFieldEditMenuClipboardTest { private companion object { private const val TextFieldTag = "TextField" - private const val ClipboardSentinel = "Clipboard sentinel" private const val TargetWord = "target" private const val FirstWordPosition = 0.2f From 5848a5cf3ef0d4439d48fcc6f671a8b2adf15e29 Mon Sep 17 00:00:00 2001 From: Janina Davydova Date: Wed, 17 Jun 2026 17:51:35 +0200 Subject: [PATCH 5/5] + TextFieldEditMenuClipboardTest --- .../TextFieldEditMenuClipboardTest.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt index b3a90e01fd7ff..2da676a51c2d9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interaction/TextFieldEditMenuClipboardTest.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf @@ -33,6 +34,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.findNodeWithLabelOrNull import androidx.compose.ui.test.findNodeWithTag +import androidx.compose.ui.test.isVisibleInContainer import androidx.compose.ui.test.runUIKitInstrumentedTest import androidx.compose.ui.test.tapContextMenuButton import androidx.compose.ui.test.waitForContextMenu @@ -121,24 +123,31 @@ class TextFieldEditMenuClipboardTest { fun testTextFieldContextMenu_CopyPasteMixedLatinJapaneseText() = runClipboardMenuTest { textFieldKind -> val mixedText = "Tokyo\u6771\u4EAC" - val initialText = "$mixedText $TargetWord" - val expectedText = "$mixedText $mixedText" val content = setTextFieldContent( textFieldKind = textFieldKind, - initialText = initialText, + initialText = mixedText, ) UIPasteboard.generalPasteboard().string = null openToolbarForWord(xFraction = FirstWordPosition) + tapContextMenuButton("Select All") + waitUntil("${"Copy"} menu item should be visible") { + findNodeWithLabelOrNull("Copy")?.isVisibleInContainer == true + } + delay(60000) tapContextMenuButton("Copy") waitUntilContextMenuClosed("Copy") - openToolbarForWord(xFraction = SecondWordPosition) + content.clearText() + waitUntil("Text field should be empty before paste") { + content.text().isEmpty() + } + openToolbarForCollapsedCursor(xFraction = TextEndPosition) tapContextMenuButton("Paste") - waitUntil("Text field should replace target word with mixed Latin/Japanese text") { - content.text() == expectedText + waitUntil("Text field should paste mixed Latin/Japanese text") { + content.text() == mixedText } assertEquals(mixedText, UIPasteboard.generalPasteboard().string) } @@ -229,6 +238,7 @@ class TextFieldEditMenuClipboardTest { } return TextFieldContent( text = { textFieldValue.value.text }, + clearText = { textFieldValue.value = TextFieldValue() }, ) } @@ -244,6 +254,7 @@ class TextFieldEditMenuClipboardTest { } return TextFieldContent( text = { textFieldState.text.toString() }, + clearText = { textFieldState.clearText() }, ) } @@ -309,6 +320,7 @@ class TextFieldEditMenuClipboardTest { private data class TextFieldContent( val text: () -> String, + val clearText: () -> Unit, ) private enum class EditableTextFieldKind {