diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index 86bf3b1cd0..acb6f6d5da 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -139,8 +139,10 @@ Delete anything explaining WHAT a step does. ## Reference docs - **`reference/compose-traps.md`** — read when the screen uses `PullToRefreshBox`, - `TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, or a hot-wallet import with - an access code. These have silent failure modes that look like passing tests. + `TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, a hot-wallet import with + an access code, or a target inside a **LazyColumn/LazyRow that may be below the fold** (use a + `KLazyListNode` matcher that auto-scrolls — never a manual swipe). These have silent failure modes that + look like passing tests. - **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using `@Ignore`, or driving WireMock scenarios. \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index edb1662a86..0458caa898 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -95,6 +95,65 @@ fun walletNameValue(name: String) = child { withText(name); useUnmergedTree = tr fun walletNameValue(name: String) = child { hasText(name); useUnmergedTree = true } ``` +## LazyList item below the fold: plain `child { }` finds it but can't click it + +A `child { hasTestTag(ITEM); hasAnyDescendant(withText(name)) }` matcher resolves the semantics node +even when the item is composed **off-screen** (LazyColumn keeps a few items past the viewport). But the +node isn't displayed, so `clickWithAssertion()` (`assertIsDisplayed()` first) fails, or `performClick()` +taps nothing. Symptom: the test passes when the item happens to be near the top and fails for items +lower in the list — and a manual swipe "fixes" it. Do **not** patch with a swipe (flaky, the +`clickableSingle` 500ms debounce can also eat fast programmatic clicks). + +**Whenever a target lives in a LazyColumn/LazyRow and might be below the fold, build a `KLazyListNode` +matcher up front** — `childWith` scrolls the list to the item before returning it: + +```kotlin +import com.tangem.common.utils.LazyListItemNode +import com.tangem.core.ui.utils.LazyListItemPositionSemantics +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode + +private val tokensList = KLazyListNode( + semanticsProvider = semanticsProvider, // primary-ctor param is in scope in initializers + viewBuilderAction = { hasTestTag(SomeScreenTestTags.LAZY_LIST) }, // the LazyColumn's OWN tag + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) }, +) + +@OptIn(ExperimentalTestApi::class) +fun tokenWithTitle(title: String): LazyListItemNode = + tokensList.childWith { + hasTestTag(SomeScreenTestTags.LAZY_LIST_ITEM) + hasText(title) + useUnmergedTree = true + } +``` + +Non-obvious points that bite: + +- **`childWith` searches the MERGED tree** (it scopes via the list's `viewBuilderAction`, whose + `useUnmergedTree` defaults to `false`). So match the item by `hasText(title)` — on a `MergeDescendants` + item the child texts aggregate onto the item node. `hasAnyDescendant(withText(...))` does **not** match + there. (`useUnmergedTree = true` on the item matcher is inert for the scroll/filter but harmless; keep + it to mirror existing page objects.) +- **The list needs its OWN `testTag` on the `LazyColumn`.** If production tags only the *items* (e.g. + `MARKETS_TOKENS_LIST_ITEM`) and not the container, add a tag to the `LazyColumn` modifier in the + production composable. Reuse the screen's existing `…TestTags.LAZY_LIST` constant when one fits. +- **Scope to the right list when several coexist.** Multiple LazyColumns with the same *item* tag can be + composed at once (e.g. the Add-Funds `ChooseTokenScreen` list AND the main-screen markets sheet, both + using `MARKETS_TOKENS_LIST_ITEM`). A bare top-level `child { hasTestTag(ITEM); … }` is then ambiguous + and may match the wrong screen. `childWith` (and `tokensList.child { … }`) scope through the container + tag via `onNode(LAZY_LIST)` / `hasAnyAncestor(LAZY_LIST)`, so they pick the intended list. Prefer a + unique container tag over hoping the item text is unique. +- **`childWith` returns a `LazyListItemNode`, not a `KNode`.** `clickWithAssertion()` was a `KNode` + extension; it's been generalized to `fun BaseNode<*>.clickWithAssertion()` (in + `common/extensions/KNode.kt`) so it works on both. Both types extend `BaseNode`, and + `assertIsDisplayed()`/`performClick()` live on `BaseNode`. +- `positionMatcher` is only used by `childAt(index)` / `hasLazyListItemPosition`. For `childWith` + (match-by-content) the items don't need to expose `LazyListItemPositionSemantics` — pass the matcher + anyway since the constructor requires it. + +Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). + ## Decompose model lifecycle vs. data refresh Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning diff --git a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt index ee98f114ff..04c925b312 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/extensions/KNode.kt @@ -4,9 +4,10 @@ import android.os.SystemClock import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeTestRule +import io.github.kakaocup.compose.node.core.BaseNode import io.github.kakaocup.compose.node.element.KNode -fun KNode.clickWithAssertion() { +fun BaseNode<*>.clickWithAssertion() { assertIsDisplayed() performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt index e2e2b20eb2..abb099d1a1 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddFundsBottomSheetPageObject.kt @@ -1,16 +1,21 @@ package com.tangem.screens +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import com.tangem.common.BaseTestCase +import com.tangem.common.utils.LazyListItemNode import com.tangem.core.ui.R import com.tangem.core.ui.test.BaseBottomSheetTestTags import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.test.MarketsTestTags import com.tangem.core.ui.test.TokenActionsTestTags import com.tangem.core.ui.test.TokenElementsTestTags +import com.tangem.core.ui.utils.LazyListItemPositionSemantics import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.KNode +import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.kakao.common.utilities.getResourceString import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasText as withText @@ -53,11 +58,22 @@ class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractions useUnmergedTree = true } - fun trendingTokenWithTitle(tokenTitle: String): KNode = child { - hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) - hasAnyDescendant(withText(tokenTitle)) - useUnmergedTree = true - } + private val trendingTokensList = KLazyListNode( + semanticsProvider = semanticsProvider, + viewBuilderAction = { hasTestTag(BuyTokenScreenTestTags.LAZY_LIST) }, + itemTypeBuilder = { itemType(::LazyListItemNode) }, + positionMatcher = { position -> + SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) + }, + ) + + @OptIn(ExperimentalTestApi::class) + fun trendingTokenWithTitle(tokenTitle: String): LazyListItemNode = + trendingTokensList.childWith { + hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } } internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt index 3d7345f883..e484f38fd4 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/AppCurrencyTest.kt @@ -54,13 +54,22 @@ class AppCurrencyTest : BaseTestCase() { step("Click on currency '$targetCurrency'") { onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() } } - step("Press 'Back' button to return to 'Details' screen") { + step("Assert 'App settings' screen is open after currency selection") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onAppSettingsScreen { currencyButton.assertIsDisplayed() } + } + } + step("Return to 'Details' screen") { waitForIdle() device.uiDevice.pressBack() } - step("Press 'Back' button to return to 'Main' screen") { - waitForIdle() - device.uiDevice.pressBack() + step("Return to 'Main' screen via 'Back' button") { + onDetailsScreen { topAppBarBackButton.clickWithAssertion() } + } + step("Assert 'Main' screen is opened") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { screenContainer.assertIsDisplayed() } + } } step("Assert total balance contains '$targetSymbol' on 'Main' screen") { // Balance re-loads in the new currency async after the switch — wait for the € equivalent. diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt index 1ef7c4baf1..029a430d70 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/MainScreenActionButtonsTest.kt @@ -501,7 +501,9 @@ class MainScreenActionButtonsTest : BaseTestCase() { onMainScreen { swapButton.performClick() } } step("Check 'Action is unavailable' dialog") { - checkActionIsUnavailableDialog() + flakySafely(WAIT_UNTIL_TIMEOUT) { + checkActionIsUnavailableDialog() + } } step("Click on 'Ok' button") { onDialog { okButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt index fb4483da57..c2278600d3 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/StellarWarningsTest.kt @@ -3,11 +3,13 @@ package com.tangem.tests.send.warnings import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendConfirmScreen @@ -110,8 +112,10 @@ class StellarWarningsTest : BaseTestCase() { step("Type non activated address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid reserve amount warning' is not displayed") { checkSendWarning( diff --git a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt index 0dbf99fde6..179376d9d2 100644 --- a/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt +++ b/features/common-features/impl/src/main/java/com/tangem/features/commonfeatures/impl/choosetoken/ui/ChooseTokenScreen.kt @@ -126,7 +126,8 @@ private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) { LazyColumn( modifier = Modifier .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .nestedScroll(nestedScrollConnection) + .testTag(BuyTokenScreenTestTags.LAZY_LIST), state = lazyListState, contentPadding = WindowInsets.navigationBars.asPaddingValues(), ) {