From 9b6ed88d78652f6d2f561363e6854bbe31424bdf Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 29 Jun 2026 09:42:11 +0200 Subject: [PATCH] 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, ) {