From 9b6ed88d78652f6d2f561363e6854bbe31424bdf Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 09:42:11 +0200 Subject: [PATCH 1/4] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 156 ++++++++ .../screens/AddToPortfolioPageObject.kt | 27 ++ .../tangem/tests/transfer/AppTransfersTest.kt | 332 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 62 ++++ .../core/ui/test/BuyTokenScreenTestTags.kt | 1 + .../core/ui/test/DetailsScreenTestTags.kt | 1 + .../impl/choosetoken/ui/ChooseTokenScreen.kt | 1 + .../details/ui/UserWalletListBlock.kt | 2 +- 8 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt new file mode 100644 index 0000000000..76eb6ba809 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -0,0 +1,156 @@ +package com.tangem.scenarios + +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.extensions.isDisplayedSafely +import com.tangem.core.ui.test.BuyTokenScreenTestTags +import com.tangem.core.ui.test.DetailsScreenTestTags +import com.tangem.core.ui.test.MainScreenTestTags +import com.tangem.screens.* +import com.tangem.tap.domain.sdk.mocks.MockContent +import com.tangem.tap.domain.sdk.mocks.MockProvider +import io.qameta.allure.kotlin.Allure.step + +// The 'Add Wallet' button sits in a nested LazyColumn and rejects touch injection, so invoke OnClick directly. +private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { + composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) +} + +// Both pager pages stay mounted; the off-screen one ignores swipes, so always swipe the on-screen wallet card. +private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { + val nodes = composeTestRule.onAllNodes(hasTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) + for (i in 0 until nodes.fetchSemanticsNodes().size) { + val swiped = runCatching { + nodes[i].assertIsDisplayed() + nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() } + }.isSuccess + if (swiped) break + } + waitForIdle() +} + +/** + * Adds a second hardware (card) wallet from a wallet-restricted state (a hot wallet already exists): + * 'Add Wallet' scans a card immediately — there is no wallet-type chooser — so the scan mock must be + * set before the click. After saving, the app returns to Main; the added card lands as an off-screen + * pager page, so this swipes to it and syncs its missing addresses before returning. + */ +fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { + step("Click 'More' button on TopBar") { + onMainScreenTopBar { moreButton.clickWithAssertion() } + } + MockProvider.setMocks(mockContent) + step("Click on 'Add Wallet' button (scans a new hardware wallet)") { + clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) + } + // Details pops back to Main only after the freshly scanned wallet finishes its initial load over many (some failing) RPCs. + // Gate on the top-bar More button, not the screen container — the bottom Markets sheet can leave the container un-"displayed". + step("Assert 'Main' screen is displayed with the new wallet") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess + } + } + // The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to. + step("Synchronize the new card wallet's addresses") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + var shown = false + onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() } + if (!shown) swipeDisplayedWallet(toPrevious = false) + shown + } + // Let the pager fling settle before clicking — the wait above releases as soon as the prompt has bounds, and a + // click landing mid-animation is eaten by the button's clickableSingle debounce, so the prompt never clears. + waitForIdle() + onMainScreen { synchronizeAddressesButton.clickWithAssertion() } + // The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs). + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { + var generated = false + onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() } + generated + } + } +} + +/** Taps the on-screen token row by [tokenName]; the wallet pager keeps both pages mounted, so click the displayed copy. */ +fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { + val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) + step("Click on token '$tokenName' on the visible wallet") { + // Both pager pages mount the same token; click the one currently on-screen once the swipe has settled. + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) + (0 until nodes.fetchSemanticsNodes().size).any { i -> + runCatching { + nodes[i].assertIsDisplayed() + nodes[i].performClick() + }.isSuccess + } + } + } +} + +/** Swipes the wallet card to the previous wallet (the one added before the current page). */ +fun BaseTestCase.switchToPreviousWallet() { + step("Swipe wallet card to the previous wallet") { + swipeDisplayedWallet(toPrevious = true) + } +} + +/** Opens the receive selector and picks identical [token] located on a different wallet [walletName] via its wallet tab. */ +fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + // The wallet tab is a custom clickable Row that Kakao's performClick can't hit (autoscroll fails); invoke OnClick directly. + step("Select wallet tab '$walletName'") { + clickViaSemantics( + hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) and hasAnyDescendant(hasText(walletName)), + useUnmergedTree = true, + ) + } + step("Click on token with name '$token'") { + clickViaSemantics( + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) and hasAnyDescendant(hasText(token)), + useUnmergedTree = true, + ) + } +} + +/** Searches the receive selector for [token], adds it to recipient wallet [recipientWalletName] via the market result. */ +fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) { + step("Click on 'Choose token' button") { + onSwapTokenScreen { chooseTokenButton.performClick() } + } + step("Type '$token' in search field") { + onSwapSelectTokenScreen { + searchBarBlock.performClick() + searchBarBlock.performTextInput(token) + } + } + step("Click on market token '$token'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess + } + } + // The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it). + step("Assert recipient wallet '$recipientWalletName' is selected") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess + } + } + step("Click on 'Add' button") { + onAddToPortfolioScreen { addButton.performClick() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt new file mode 100644 index 0000000000..9046abeb11 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/AddToPortfolioPageObject.kt @@ -0,0 +1,27 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasClickAction +import com.tangem.common.BaseTestCase +import com.tangem.wallet.R +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.kakao.common.utilities.getResourceString + +class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun walletName(walletName: String): KNode = child { + hasText(walletName) + useUnmergedTree = true + } + + val addButton: KNode = child { + hasText(getResourceString(R.string.common_add)) + hasClickAction() + } +} + +internal fun BaseTestCase.onAddToPortfolioScreen(function: AddToPortfolioPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 14e9fb1557..6c91d75161 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -5,14 +5,18 @@ import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.extractText import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 +import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_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.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState import com.tangem.core.ui.R as CoreUiR import com.tangem.scenarios.* import com.tangem.screens.* +import com.tangem.screens.tangempay.* import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent +import com.tangem.tap.domain.sdk.mocks.content.WalletMockContent import dagger.hilt.android.testing.HiltAndroidTest import io.github.kakaocup.kakao.common.utilities.getResourceString import io.qameta.allure.kotlin.Allure.step @@ -808,6 +812,334 @@ class AppTransfersTest : BaseTestCase() { } } + @AllureId("10005") + @DisplayName("App transfers: Tron network fee") + @Test + fun tronNetworkFeeTest() { + val token = "Tron" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTron" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + // [REDACTED_TASK_KEY]: blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. + @Ignore("[REDACTED_JIRA]") + @AllureId("10012") + @DisplayName("App transfers: TON network fee") + @Test + fun tonNetworkFeeTest() { + // The SDK names TON's coin "Gram" (Blockchain.TON.getCoinName), so the portfolio row shows "Gram", not "Toncoin". + val token = "Gram" + val amount = "0.001" + val userTokensState = "TwoAccountsSameTON" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10013") + @DisplayName("App transfers: Cosmos network fee") + @Test + fun cosmosNetworkFeeTest() { + val token = "Cosmos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameCosmos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("10015") + @DisplayName("App transfers: Aptos network fee") + @Test + fun aptosNetworkFeeTest() { + val token = "Aptos" + val amount = "0.001" + val userTokensState = "TwoAccountsSameAptos" + val networksProvidersScenario = "networks_providers" + val appTransfersNetworksState = "AppTransfersNetworks" + + setupHooks( + additionalBeforeAppLaunchSection = { + setWireMockScenarioState(storiesScenario, storiesErrorState) + // networks_providers configures SDK RPC hosts at launch — must be set before the activity starts. + setWireMockScenarioState(networksProvidersScenario, appTransfersNetworksState) + }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(networksProvidersScenario) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + // Non-zero prices keep total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet. + step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$appTransfersNetworksState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = appTransfersNetworksState) + } + + step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + } + } + + @AllureId("9856") + @DisplayName("App transfers: Transfer mode is available from a Tangem Pay account") + @Test + fun transferModeAvailableFromTangemPayAccountTest() { + val token = "USDC" + val receiveAccountName = "Main account" + val eligibilityState = "PaeraCustomer" + val balanceScenario = "tangem_pay_balance_update" + val balanceInitialState = "InitialBalance" + val historyScenario = "tangem_pay_transaction_history" + val historyInitialState = "InitialEmpty" + val userTokensState = "TangemPayTransferUsdc" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO) + resetWireMockScenarioState(balanceScenario) + resetWireMockScenarioState(historyScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + } + ).run { + step("Set WireMock scenario: '$TANGEM_PAY_ELIGIBILITY_SCENARIO' to state: '$eligibilityState'") { + setWireMockScenarioState(scenarioName = TANGEM_PAY_ELIGIBILITY_SCENARIO, state = eligibilityState) + } + step("Set WireMock scenario: '$balanceScenario' to state: '$balanceInitialState'") { + setWireMockScenarioState(scenarioName = balanceScenario, state = balanceInitialState) + } + step("Set WireMock scenario: '$historyScenario' to state: '$historyInitialState'") { + setWireMockScenarioState(scenarioName = historyScenario, state = historyInitialState) + } + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + + step("Open Tangem Pay") { openTangemPay() } + step("Click on 'Withdraw' button") { + onTangemPayMainScreen { withdrawButton.clickWithAssertion() } + } + step("Acknowledge withdrawal note sheet") { + onTangemPayWithdrawNoteSheet { + title.assertIsDisplayed() + gotItButton.clickWithAssertion() + } + } + step("Choose identical receive token '$token' from '$receiveAccountName'") { + chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName) + } + // Assert the top-bar 'Transfer' title (unambiguous transfer-mode signal) + no provider block; the + // withdraw-entry swap keeps recalculating, so use flakySafely rather than assertTransferReady's waitUntil. + step("Assert Transfer mode is ready") { + flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) { + onSwapTokenScreen { transferTitle.assertIsDisplayed() } + } + onSwapTokenScreen { providersBlock.assertIsNotDisplayed() } + } + } + } + + @AllureId("9995") + @DisplayName("App transfers: transfer between different wallets reaches 'Transfer in progress' screen") + @Test + fun transferBetweenDifferentWalletsReachesFinishTest() { + val token = "Ethereum" + val amount = "0.001" + val secondWalletName = "Wallet 2" + val userTokensState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Add a second card wallet '$secondWalletName'") { + addNewCardWallet(WalletMockContent) + } + step("Switch back to the hot wallet") { switchToPreviousWallet() } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Select identical receive token '$token' on '$secondWalletName'") { + selectReceiveTokenOnWallet(token = token, walletName = secondWalletName) + } + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Hold to confirm the transfer") { holdToConfirmTransfer() } + step("Assert 'Transfer in progress' screen is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSuccessScreen { transferInProgressTitle.assertIsDisplayed() } + } + } + } + } + + @AllureId("9996") + @DisplayName("App transfers: adding a missing token to the recipient wallet enables Transfer") + @Test + fun addMissingTokenToRecipientWalletEnablesTransferTest() { + val token = "Ethereum" + val bitcoinToken = "Bitcoin" + val recipientWalletName = "Wallet" + val recipientWithoutEthereumState = "RecipientWithoutEthereum" + val ethereumWithSecondTokenState = "EthereumWithSecondToken" + + setupHooks( + additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) }, + additionalAfterSection = { + resetWireMockScenarioState(storiesScenario) + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(ethCallScenario) + resetWireMockScenarioState(ethBalanceScenario) + } + ).run { + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$recipientWithoutEthereumState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = recipientWithoutEthereumState) + } + step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethCallScenario, state = started) + } + step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") { + setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started) + } + + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12) + } + step("Generate missing addresses") { generateMissingHotWalletAddresses() } + step("Wait for addresses to be generated") { waitForAddressesGenerated() } + step("Assert token '$bitcoinToken' is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onMainScreen { tokenWithTitleAndAddress(bitcoinToken).assertIsDisplayed() } + } + } + // Switch the user-tokens mock so the second wallet loads with Ethereum while the recipient stays Ethereum-less. + step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$ethereumWithSecondTokenState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = ethereumWithSecondTokenState) + } + step("Add a second card wallet") { + addNewCardWallet(WalletMockContent) + } + step("Click on token with name: '$token'") { clickDisplayedTokenOnMain(token) } + step("Open 'Swap' screen") { + openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) + } + step("Add missing token '$token' to recipient wallet '$recipientWalletName'") { + addMissingReceiveTokenToWallet(token = token, recipientWalletName = recipientWalletName) + } + step("Assert Transfer mode is ready") { assertTransferReady() } + } + } + // [REDACTED_TASK_KEY]: transfer mode never runs tx validation, so the destination rent-exemption notification never shows. @Ignore("[REDACTED_JIRA]") @AllureId("9852") diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index aa7881a6db..a8c47499e7 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -132,6 +132,10 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), ), + DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + ), DerivationPath("m/1852'/1815'/0'/0/0") to ExtendedPublicKey( publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -181,6 +185,22 @@ object WalletMockContent : MockContent { publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), ), + DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1) + publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43), + chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40), + ), + DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2) + publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111), + chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126), + ), + DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), + DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2) + publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), + chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), + ), ), extendedPublicKey = ExtendedPublicKey( publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), @@ -290,6 +310,20 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/118'/0'/0/0") to ExtendedPublicKey( // Cosmos (account 1) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/118'/1'/0/0") to ExtendedPublicKey( // Cosmos (account 2) + publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), + chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), DerivationPath("m/44'/144'/0'/0/0") to ExtendedPublicKey( // XRP publicKey = byteArrayOf(2, -40, 115, -15, 80, 104, 100, -29, 80, 29, 112, -35, -54, 64, -98, 45, 115, 108, 93, 113, -71, 89, 17, 72, 98, 21, 126, 82, -50, 50, -12, -87, -62), chainCode = byteArrayOf(-27, 56, 57, 54, 27, -40, 65, 109, 119, -54, -94, 88, -29, -115, -45, -112, -31, 57, 53, 56, 118, 87, 34, -16, -64, -45, 105, 77, 91, -106, -92, 41), @@ -424,6 +458,34 @@ object WalletMockContent : MockContent { parentFingerprint = byteArrayOf(0, 0, 0, 0), childNumber = 0, ), + DerivationPath("m/44'/607'/0'/0/0") to ExtendedPublicKey( // TON (account 1) + publicKey = byteArrayOf(30, -109, -39, 33, -94, -73, 121, 50, -75, 86, 102, -2, -74, -23, 63, -3, 79, -82, -103, 106, 82, -86, -107, -63, -46, 104, 7, 18, -41, 15, -87, -43), + chainCode = byteArrayOf(15, 61, -29, 22, 30, 45, -51, -60, 5, 62, -87, -35, 54, -97, -5, -44, -54, -107, -14, -119, -3, 92, 91, 75, -66, 26, 112, 83, 122, -25, -64, 40), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/607'/1'/0/0") to ExtendedPublicKey( // TON (account 2) + publicKey = byteArrayOf(-51, 62, 97, 25, 83, 75, -79, 23, 6, -42, -94, 45, 91, -66, 57, -80, -75, -39, 19, -88, 95, -124, 50, 39, 114, -118, 27, -122, 48, -69, 7, -111), + chainCode = byteArrayOf(77, 63, 69, -114, -25, 105, -123, -42, -87, 107, 86, -43, 46, 92, -78, -107, -72, -81, -102, 45, 75, 97, -120, -10, 118, 27, -34, -50, -92, 3, 47, -126), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/637'/0'/0'/0'") to ExtendedPublicKey( // Aptos (account 1) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), + DerivationPath("m/44'/637'/1'/0'/0'") to ExtendedPublicKey( // Aptos (account 2) + publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82), + chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), + depth = 0, + parentFingerprint = byteArrayOf(0, 0, 0, 0), + childNumber = 0, + ), ), ), diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt index af41f67814..d42256592d 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/BuyTokenScreenTestTags.kt @@ -3,4 +3,5 @@ package com.tangem.core.ui.test object BuyTokenScreenTestTags { const val LAZY_LIST = "BUY_TOKEN_SCREEN_LAZY_LIST" const val LAZY_LIST_ITEM = "BUY_TOKEN_SCREEN_LAZY_LIST_ITEM" + const val WALLET_TAB = "BUY_TOKEN_SCREEN_WALLET_TAB" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt index ec91e0b88a..7ef23e03ea 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/test/DetailsScreenTestTags.kt @@ -5,4 +5,5 @@ object DetailsScreenTestTags { const val SCREEN_ITEM = "DETAILS_SCREEN_ITEM" const val VERSION_NAME = "DETAILS_SCREEN_VERSION_NAME" const val USER_WALLET_ITEM = "DETAILS_SCREEN_USER_WALLET_ITEM" + const val ADD_WALLET_BUTTON = "DETAILS_SCREEN_ADD_WALLET_BUTTON" } \ No newline at end of file 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 18fbcd8716..de5b55289b 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 @@ -252,6 +252,7 @@ private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) { .clip(RoundedCornerShape(percent = 50)) .background(backgroundColor) .clickable(onClick = state.onClick) + .testTag(BuyTokenScreenTestTags.WALLET_TAB) .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, diff --git a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt index df93d20331..6f9876af34 100644 --- a/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt +++ b/features/details/impl/src/main/kotlin/com/tangem/features/details/ui/UserWalletListBlock.kt @@ -130,7 +130,7 @@ private fun AddWalletButton( modifier: Modifier = Modifier, ) { BlockCard( - modifier = modifier, + modifier = modifier.testTag(DetailsScreenTestTags.ADD_WALLET_BUTTON), onClick = onClick, enabled = !isInProgress, ) { From a434e954df7bc3ea8226df927fccd930b5129d1a Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 11:16:41 +0200 Subject: [PATCH 2/4] Updated on 2026-08-14 --- .../kotlin/com/tangem/scenarios/SwapScenarios.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index ad101a5545..5634025ac7 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -490,11 +490,12 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } -/** Holds the last BASE_BUTTON to confirm a transfer; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ +/** Holds the Transfer confirm button; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ fun BaseTestCase.holdToConfirmTransfer() { - val buttons = composeTestRule.onAllNodes(hasTestTag(BaseButtonTestTags.BUTTON)) - val confirmButton = buttons[buttons.fetchSemanticsNodes().lastIndex] - confirmButton.performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } + composeTestRule.onNode( + hasTestTag(BaseButtonTestTags.BUTTON) and + hasText(getResourceString(CoreUiR.string.swapping_transfer_action)), + ).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) } waitForIdle() } From de41c92d35f847090cf583838f25437a1af05c96 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 11:50:47 +0200 Subject: [PATCH 3/4] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 23 ++++++------------- .../com/tangem/scenarios/SwapScenarios.kt | 2 +- .../tangem/tests/transfer/AppTransfersTest.kt | 5 ++-- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt index 76eb6ba809..8551ce9af5 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -24,7 +24,7 @@ import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step -// The 'Add Wallet' button sits in a nested LazyColumn and rejects touch injection, so invoke OnClick directly. +// Custom/nested clickables (Add-Wallet row, wallet tab) reject touch injection — drive OnClick directly. private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) } @@ -42,12 +42,7 @@ private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { waitForIdle() } -/** - * Adds a second hardware (card) wallet from a wallet-restricted state (a hot wallet already exists): - * 'Add Wallet' scans a card immediately — there is no wallet-type chooser — so the scan mock must be - * set before the click. After saving, the app returns to Main; the added card lands as an off-screen - * pager page, so this swipes to it and syncs its missing addresses before returning. - */ +/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { step("Click 'More' button on TopBar") { onMainScreenTopBar { moreButton.clickWithAssertion() } @@ -56,8 +51,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { step("Click on 'Add Wallet' button (scans a new hardware wallet)") { clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) } - // Details pops back to Main only after the freshly scanned wallet finishes its initial load over many (some failing) RPCs. - // Gate on the top-bar More button, not the screen container — the bottom Markets sheet can leave the container un-"displayed". + // Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed". step("Assert 'Main' screen is displayed with the new wallet") { composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) { runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess @@ -71,8 +65,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { if (!shown) swipeDisplayedWallet(toPrevious = false) shown } - // Let the pager fling settle before clicking — the wait above releases as soon as the prompt has bounds, and a - // click landing mid-animation is eaten by the button's clickableSingle debounce, so the prompt never clears. + // Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce. waitForIdle() onMainScreen { synchronizeAddressesButton.clickWithAssertion() } // The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs). @@ -84,11 +77,10 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } } -/** Taps the on-screen token row by [tokenName]; the wallet pager keeps both pages mounted, so click the displayed copy. */ +/** Both pager pages mount the same token; clicks the [tokenName] copy currently on-screen. */ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) step("Click on token '$tokenName' on the visible wallet") { - // Both pager pages mount the same token; click the one currently on-screen once the swipe has settled. composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) (0 until nodes.fetchSemanticsNodes().size).any { i -> @@ -101,14 +93,13 @@ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { } } -/** Swipes the wallet card to the previous wallet (the one added before the current page). */ fun BaseTestCase.switchToPreviousWallet() { step("Swipe wallet card to the previous wallet") { swipeDisplayedWallet(toPrevious = true) } } -/** Opens the receive selector and picks identical [token] located on a different wallet [walletName] via its wallet tab. */ +/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */ fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } @@ -128,7 +119,7 @@ fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { } } -/** Searches the receive selector for [token], adds it to recipient wallet [recipientWalletName] via the market result. */ +/** Adds [token] to [recipientWalletName] which lacks it, via market search. */ fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 5634025ac7..4dc9ab8da6 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -490,7 +490,7 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { } } -/** Holds the Transfer confirm button; the caller asserts the outcome (transfer mode has no in-progress marker to wait on). */ +// Caller asserts the outcome — transfer mode has no in-progress marker to wait on. fun BaseTestCase.holdToConfirmTransfer() { composeTestRule.onNode( hasTestTag(BaseButtonTestTags.BUTTON) and diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 6c91d75161..1904bd4c09 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -850,7 +850,7 @@ class AppTransfersTest : BaseTestCase() { } } - // [REDACTED_TASK_KEY]: blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. + // blockchain SDK TonProvidersBuilder drops public providers, so TON has no provider in the mocked build. @Ignore("[REDACTED_JIRA]") @AllureId("10012") @DisplayName("App transfers: TON network fee") @@ -1016,8 +1016,7 @@ class AppTransfersTest : BaseTestCase() { step("Choose identical receive token '$token' from '$receiveAccountName'") { chooseIdenticalReceiveToken(tokenName = token, receiveAccountName = receiveAccountName) } - // Assert the top-bar 'Transfer' title (unambiguous transfer-mode signal) + no provider block; the - // withdraw-entry swap keeps recalculating, so use flakySafely rather than assertTransferReady's waitUntil. + // Withdraw-entry swap keeps recalculating — use flakySafely rather than assertTransferReady's waitUntil. step("Assert Transfer mode is ready") { flakySafely(WAIT_UNTIL_TIMEOUT_VERY_LONG) { onSwapTokenScreen { transferTitle.assertIsDisplayed() } From 392234221b7b07e206221e6098247c9bb21f1baa Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 14:47:35 +0200 Subject: [PATCH 4/4] Updated on 2026-08-14 --- .../tangem/scenarios/MultiWalletScenarios.kt | 61 ++----------------- .../com/tangem/screens/BuyTokenPageObject.kt | 14 +++++ .../com/tangem/screens/DetailsPageObject.kt | 4 ++ .../tangem/screens/MainScreenPageObject.kt | 22 +++++++ 4 files changed, 46 insertions(+), 55 deletions(-) diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt index 8551ce9af5..dc3ef704c7 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/MultiWalletScenarios.kt @@ -1,47 +1,15 @@ package com.tangem.scenarios -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasAnyDescendant -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performSemanticsAction -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeLeft -import androidx.compose.ui.test.swipeRight import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.isDisplayedSafely -import com.tangem.core.ui.test.BuyTokenScreenTestTags -import com.tangem.core.ui.test.DetailsScreenTestTags -import com.tangem.core.ui.test.MainScreenTestTags import com.tangem.screens.* import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockProvider import io.qameta.allure.kotlin.Allure.step -// Custom/nested clickables (Add-Wallet row, wallet tab) reject touch injection — drive OnClick directly. -private fun BaseTestCase.clickViaSemantics(matcher: SemanticsMatcher, useUnmergedTree: Boolean = false) { - composeTestRule.onNode(matcher, useUnmergedTree).performSemanticsAction(SemanticsActions.OnClick) -} - -// Both pager pages stay mounted; the off-screen one ignores swipes, so always swipe the on-screen wallet card. -private fun BaseTestCase.swipeDisplayedWallet(toPrevious: Boolean) { - val nodes = composeTestRule.onAllNodes(hasTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) - for (i in 0 until nodes.fetchSemanticsNodes().size) { - val swiped = runCatching { - nodes[i].assertIsDisplayed() - nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() } - }.isSuccess - if (swiped) break - } - waitForIdle() -} - /** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { step("Click 'More' button on TopBar") { @@ -49,7 +17,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } MockProvider.setMocks(mockContent) step("Click on 'Add Wallet' button (scans a new hardware wallet)") { - clickViaSemantics(hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)) + onDetailsScreen { addWalletButton.clickWithAssertion() } } // Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed". step("Assert 'Main' screen is displayed with the new wallet") { @@ -62,7 +30,7 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { var shown = false onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() } - if (!shown) swipeDisplayedWallet(toPrevious = false) + if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) } shown } // Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce. @@ -77,25 +45,15 @@ fun BaseTestCase.addNewCardWallet(mockContent: MockContent) { } } -/** Both pager pages mount the same token; clicks the [tokenName] copy currently on-screen. */ fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) { - val matcher = hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(hasText(tokenName)) step("Click on token '$tokenName' on the visible wallet") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - val nodes = composeTestRule.onAllNodes(matcher, useUnmergedTree = true) - (0 until nodes.fetchSemanticsNodes().size).any { i -> - runCatching { - nodes[i].assertIsDisplayed() - nodes[i].performClick() - }.isSuccess - } - } + onMainScreen { clickDisplayedToken(tokenName) } } } fun BaseTestCase.switchToPreviousWallet() { step("Swipe wallet card to the previous wallet") { - swipeDisplayedWallet(toPrevious = true) + onMainScreen { swipeToAdjacentWallet(toPrevious = true) } } } @@ -104,18 +62,11 @@ fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) { step("Click on 'Choose token' button") { onSwapTokenScreen { chooseTokenButton.performClick() } } - // The wallet tab is a custom clickable Row that Kakao's performClick can't hit (autoscroll fails); invoke OnClick directly. step("Select wallet tab '$walletName'") { - clickViaSemantics( - hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) and hasAnyDescendant(hasText(walletName)), - useUnmergedTree = true, - ) + onBuyTokenScreen { walletTab(walletName).performClick() } } step("Click on token with name '$token'") { - clickViaSemantics( - hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) and hasAnyDescendant(hasText(token)), - useUnmergedTree = true, - ) + onBuyTokenScreen { tokenWithTitle(token).performClick() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt index 1dbe1d3ba3..c3b236391e 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/BuyTokenPageObject.kt @@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose 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.hasText as withText class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : ComposeScreen(semanticsProvider = semanticsProvider) { @@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } } + + fun walletTab(walletName: String): KNode = child { + hasTestTag(BuyTokenScreenTestTags.WALLET_TAB) + hasAnyDescendant(withText(walletName)) + useUnmergedTree = true + } + + @OptIn(ExperimentalTestApi::class) + fun tokenWithTitle(tokenTitle: String): LazyListItemNode = lazyList.childWith { + hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM) + hasText(tokenTitle) + useUnmergedTree = true + } } internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) = diff --git a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt index ff937e632d..110188ecbc 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/DetailsPageObject.kt @@ -28,6 +28,10 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : useUnmergedTree = true } + val addWalletButton: KNode = child { + hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON) + } + val buyTangemButton: KNode = child { hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) hasText(getResourceString(R.string.details_buy_wallet)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 8e83fc81ea..c6b343f37b 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -111,6 +111,28 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti ) } + // Wallet pager keeps the adjacent page composed (beyondViewportPageCount=1), so the token is mounted on two pages — click the displayed copy. + fun clickDisplayedToken(tokenName: String) { + val matcher = withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenName)) + val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = true) + for (i in 0 until nodes.fetchSemanticsNodes().size) { + if (runCatching { nodes[i].assertIsDisplayed(); nodes[i].performClick() }.isSuccess) return + } + error("Token '$tokenName' is not displayed on the current wallet page") + } + + // Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen. + fun swipeToAdjacentWallet(toPrevious: Boolean) { + val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM)) + for (i in 0 until nodes.fetchSemanticsNodes().size) { + val swiped = runCatching { + nodes[i].assertIsDisplayed() + nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() } + }.isSuccess + if (swiped) return + } + } + val restoringProgressText: KNode = child { hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) useUnmergedTree = true