Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-22 11:20:46 +03:00
parent 9b8ca9456d
commit 633666c3ce
8 changed files with 110 additions and 16 deletions

View file

@ -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.

View file

@ -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<LazyListItemNode> {
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

View file

@ -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()
}

View file

@ -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<LazyListItemNode> {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =

View file

@ -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.

View file

@ -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() }

View file

@ -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(

View file

@ -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(),
) {