From c81ba95db0d667bd81972a4a66911f6d5fb02837 Mon Sep 17 00:00:00 2001 From: Tangem Date: Mon, 8 Jun 2026 14:31:40 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 6 + .../write-ui-test/reference/compose-traps.md | 39 +++ .../com/tangem/scenarios/GaslessScenarios.kt | 90 +++++ .../tangem/screens/SendConfirmPageObject.kt | 18 + .../SendFeeSelectorBottomSheetPageObject.kt | 65 ++++ .../com/tangem/screens/TxHistoryPageObject.kt | 37 ++ .../tests/send/gasless/GaslessFeeTest.kt | 317 ++++++++++++++++++ .../tests/send/gasless/GaslessSendTest.kt | 189 +++++++++++ .../tap/network/auth/DefaultAuthProvider.kt | 1 + .../api/common/config/GaslessTxService.kt | 9 + .../utils/WireMockRedirectInterceptor.kt | 1 + .../managers/ProdApiConfigsManagerTest.kt | 1 + .../transactions/TransactionItem.kt | 24 +- .../ui/test/TransactionHistoryItemTestTags.kt | 12 + .../success/model/SendConfirmSuccessModel.kt | 10 +- .../success/ui/SendConfirmSuccessContent.kt | 8 +- .../success/model/NFTSendSuccessModel.kt | 10 +- 17 files changed, 827 insertions(+), 10 deletions(-) create mode 100644 app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index d830f62b04..fe33038941 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -71,6 +71,12 @@ When the user asks to **port** an iOS test to Android: strings inside `step(...)`. - **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which click failed in the Allure report. +- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario + itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones + detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside + scenario extension functions; only `flakySafely` is restricted to the `TestCase` body. Caveat: don't + wrap a *mutating* scenario (e.g. one that long-clicks to sign+send) in `flakySafely` — a retry would + re-fire the action; rely on the assertion's own built-in retry instead. - **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert is displayed` / `is not displayed`. Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` / `is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it). diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 413a932acd..edb1662a86 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -102,6 +102,45 @@ to a screen via `router::pop` does NOT re-fetch. A test that switches WireMock s action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the stale in-memory data wins. +## Terminal screen never reaches Compose-idle: self-feeding `StateFlow` loop + +A screen whose model writes a fresh state back into the same `StateFlow` it observes will recompose +forever, so **any** Compose/Espresso assertion on it times out with `ComposeNotIdleException` +(`autoAdvance=true`) or `AppNotIdleException` "last message = DispatchedContinuation target=Handler" +(`autoAdvance=false`). The classic shape (hit on the send-v2 `ConfirmSuccess` screen, [REDACTED_TASK_KEY]): + +```kotlin +combine(uiState, currentRoute) + .onEach { (state, _) -> callback.onResult(state.copy(navigationUM = NavigationUM.Content(onClick = { … }))) } + // callback writes back into uiState → emits again → onEach again → ∞ +``` + +`NavigationUM.Content` is a `data class` whose fields are **lambdas**, recreated every pass → `equals` +is always false → `StateFlow` never dedups → unthrottled loop. No test-side workaround helps (it's an +app loop): not `flakySafely`, not longer timeouts, not mocking external sources, not UiAutomator +(touching the window mid-async-signing aborts the send). **Fix is app-side** — emit once (guard the +`filter`/`distinctUntilChanged` so the self-induced field is ignored). If you see `ComposeNotIdle` on a +*static-looking* success/result screen, suspect this before blaming background polling. + +## Animation-gated content via `delay()` never appears under the test clock + +Compose UI tests run inside `runTest` — **virtual time**. A `LaunchedEffect { delay(600); visible = true }` +that gates the screen body behind `AnimatedVisibility(visible)` will *never* reveal it once the +composition is otherwise idle: `waitForIdle` sees no pending frame-clock awaiters, so it stops without +advancing the virtual clock to the delay's deadline. The body stays empty (you see only the parent +chrome, e.g. a top-bar close icon), the `testTag` is absent, and `assertIsDisplayed` fails as +"not displayed" — **after** burning the full wall-clock timeout. `flakySafely(LONG)` does NOT help: +it retries in wall-clock time while virtual time stays frozen. + +Distinguish from the loop trap above: a `delay`-gate gives a clean `AssertionError: … not displayed` +(idle is reached, node just isn't there); the loop gives a `ComposeNotIdle`/`AppNotIdle` timeout. + +Fixes: (a) app-side — drop the pre-`delay`, let the enter transition (`slideIn`/`fadeIn`) play on the +frame clock (which `autoAdvance` *does* pump); or (b) put the asserted `testTag` on a node **outside** +the `AnimatedVisibility` so the container exists from frame 0. A plain coroutine `delay` is not a +frame-clock awaiter, so advancing frames won't fire it — only `advanceTimeBy` (with `autoAdvance=false`) +would, which is fragile. Prefer the app-side fix. + ## Hot wallet imports with access code - `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt` diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt new file mode 100644 index 0000000000..7617efa34f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt @@ -0,0 +1,90 @@ +package com.tangem.scenarios + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.screens.* +import io.qameta.allure.kotlin.Allure.step + +/** + * From the recipient step: fill the address and advance to the 'Send confirm' screen. + * Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in extensions on [BaseTestCase]. + */ +fun BaseTestCase.enterRecipientAndOpenSendConfirm(recipientAddress: String) { + step("Type recipient address") { + onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) } + } + step("Click on 'Next' button until 'Send confirm' screen opens") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { openSendConfirmScreenViaNextButton() }.isSuccess + } + } +} + +/** Enter the send amount, then fill the recipient and open the 'Send confirm' screen. */ +fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, recipientAddress: String) { + step("Type '$amount' in input text field") { + onSendScreen { + amountInputTextField.performClick() + amountInputTextField.performTextReplacement(amount) + } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + enterRecipientAndOpenSendConfirm(recipientAddress) +} + +/** + * On the 'Send confirm' screen, open the network-fee selector and switch the fee token from the + * native coin to the given (stablecoin) token — the core gasless action repeated across the suite. + */ +fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) { + step("Click on 'Network fee' block") { + onSendConfirmScreen { + feeSelectorBlock.assertIsDisplayed() + feeSelectorBlock.performClick() + } + } + step("Click on '$coinName' fee token to open 'Choose token'") { + composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { + runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(coinName).performClick() } }.isSuccess + } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } +} + +/** + * Open an existing hot wallet (gasless signing needs a hot wallet, not the mock card), set the + * portfolio and quotes mocks, and reach the send amount input for the given token. + */ +fun BaseTestCase.openGaslessSendScreenWithHotWallet( + seedPhrase: String, + tokenName: String, + userTokensState: String, + quotesState: String, +) { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState) + } + step("Open 'Main' screen with existing hot wallet") { + openMainScreenWithExistingHotWallet(seedPhrase) + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Click on 'Transfer' button") { + onTokenDetailsScreen { transferButton.clickWithAssertion() } + } + step("Click on 'Send' button in bottom sheet") { + onTransferBottomSheet { sendButton.clickWithAssertion() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt index 6f857ab540..2b2c9c43eb 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendConfirmPageObject.kt @@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + fun warningMessageContaining(textPart: String): KNode = child { + hasTestTag(NotificationTestTags.MESSAGE) + hasText(textPart, substring = true) + useUnmergedTree = true + } + + fun warningTitleContaining(textPart: String): KNode = child { + hasTestTag(NotificationTestTags.TITLE) + hasText(textPart, substring = true) + useUnmergedTree = true + } + fun warningIcon(message: String): KNode = child { hasTestTag(NotificationTestTags.ICON) hasAnySibling(withText(message)) @@ -145,6 +157,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider useUnmergedTree = true } + fun feeBlockCurrency(symbol: String): KNode = child { + hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK) + hasAnyDescendant(withText(symbol)) + useUnmergedTree = true + } + val refreshButton: KNode = child { hasTestTag(BaseButtonTestTags.BUTTON) hasText(getResourceString(CoreUiR.string.warning_button_refresh)) diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt new file mode 100644 index 0000000000..20d5703c90 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/SendFeeSelectorBottomSheetPageObject.kt @@ -0,0 +1,65 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.BaseBottomSheetTestTags +import com.tangem.core.ui.test.BaseButtonTestTags +import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags +import com.tangem.core.ui.test.TokenElementsTestTags +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 +import androidx.compose.ui.test.hasText as withText + +/** + * Gasless fee selector modal: the `NetworkFee` route (fee-paying token row + selected speed) and the + * `ChooseToken` route. The `ChooseSpeed` route is covered by [SendSelectNetworkFeeBottomSheetPageObject]. + */ +class SendFeeSelectorBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + val networkFeeTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.common_network_fee_title)) + useUnmergedTree = true + } + + val chooseTokenTitle: KNode = child { + hasTestTag(BaseBottomSheetTestTags.TITLE) + hasText(getResourceString(R.string.fee_selector_choose_token_title)) + useUnmergedTree = true + } + + val feeTokenRow: KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_TITLE) + useUnmergedTree = true + } + + fun feeTokenItem(tokenName: String): KNode = child { + hasTestTag(TokenElementsTestTags.TOKEN_TITLE) + hasAnyChild(withText(tokenName)) + useUnmergedTree = true + } + + fun feeSpeedItemTitle(speed: String): KNode = child { + hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE) + hasText(speed) + useUnmergedTree = true + } + + val applyButton: KNode = child { + hasTestTag(BaseButtonTestTags.BUTTON) + hasAnyDescendant(withText(getResourceString(R.string.common_apply))) + useUnmergedTree = true + } + + val notEnoughFundsError: KNode = child { + hasText(getResourceString(R.string.gasless_not_enough_funds_to_cover_token_fee)) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onSendFeeSelectorBottomSheet(function: SendFeeSelectorBottomSheetPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt new file mode 100644 index 0000000000..3ce45be03f --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/screens/TxHistoryPageObject.kt @@ -0,0 +1,37 @@ +package com.tangem.screens + +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import com.tangem.common.BaseTestCase +import com.tangem.core.ui.test.TransactionHistoryItemTestTags +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 androidx.compose.ui.test.hasText as withText + +class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : + ComposeScreen(semanticsProvider = semanticsProvider) { + + fun transactionItem(title: String): KNode = child { + hasTestTag(TransactionHistoryItemTestTags.ITEM) + hasAnyDescendant(withText(title)) + useUnmergedTree = true + } + + fun transactionAmount(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.AMOUNT) + useUnmergedTree = true + } + + fun transactionCurrency(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.CURRENCY) + useUnmergedTree = true + } + + fun transactionConfirmedStatus(title: String): KNode = transactionItem(title).child { + hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED) + useUnmergedTree = true + } +} + +internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) = + onComposeScreen(composeTestRule, function) \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt new file mode 100644 index 0000000000..4c21d6586c --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessFeeTest.kt @@ -0,0 +1,317 @@ +package com.tangem.tests.send.gasless + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO +import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.enterAmountAndOpenSendConfirm +import com.tangem.scenarios.enterRecipientAndOpenSendConfirm +import com.tangem.scenarios.openSendScreen +import com.tangem.scenarios.selectStablecoinAsFeeToken +import com.tangem.screens.onSendConfirmScreen +import com.tangem.screens.onSendFeeSelectorBottomSheet +import com.tangem.screens.onSendScreen +import com.tangem.screens.onSendSelectNetworkFeeBottomSheet +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless network-fee behaviour on the send summary (fee selector): availability, calculation, + * speed options, switching the fee token, and balance-driven notifications. All run on the default + * (cold) wallet without signing a transaction. + */ +@HiltAndroidTest +class GaslessFeeTest : BaseTestCase() { + + private val scenarioState = "PolygonUSDC" + private val tokenName = "USDC" + private val nativeTokenName = "Polygon" + private val tokenAmount = "1" + + @AllureId("5061") + @DisplayName("Gasless: Network fee on summary is selectable and the stablecoin is available for the fee") + @Test + fun checkNetworkFeeTokenSelectionAvailableTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert 'Network fee' block with token selection is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeSelectorTitle.assertIsDisplayed() + selectFeeIcon.assertIsDisplayed() + } + } + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { feeSelectorBlock.performClick() } + } + step("Assert 'Network fee' bottom sheet is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Assert '$tokenName' is available for the fee payment") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() } + } + } + } + + @AllureId("5062") + @DisplayName("Gasless: network fee for a stablecoin is calculated and shown in the stablecoin") + @Test + fun checkFeeCalculatedInStablecoinTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert the fee is shown under the '$marketSpeed' speed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() } + } + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert the network fee is calculated in '$tokenName' (not in the coin) on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeBlockCurrency(tokenName).assertIsDisplayed() + feeAmount.assertIsDisplayed() + } + } + } + } + } + + @AllureId("5064") + @DisplayName("Gasless: only Market speed is available when paying the fee with a stablecoin") + @Test + fun checkOnlyMarketSpeedAvailableForStablecoinFeeTest() { + val marketSpeed = getResourceString(R.string.common_fee_selector_option_market) + val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast) + val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Click on 'Network fee' block") { + onSendConfirmScreen { + feeSelectorBlock.assertIsDisplayed() + feeSelectorBlock.performClick() + } + } + step("Assert 'Network fee' bottom sheet is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Click on '$nativeTokenName' fee token to open 'Choose token'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Assert 'Choose token' bottom sheet is displayed") { + onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() } + } + step("Select '$tokenName' as the fee-paying token") { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + step("Assert 'Network fee' bottom sheet is displayed after token selection") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } + } + } + step("Assert '$marketSpeed' speed is displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() } + } + step("Assert '$fastSpeed' speed is not displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() } + } + step("Assert '$slowSpeed' speed is not displayed") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() } + } + step("Click on '$marketSpeed' fee row") { + onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() } + } + step("Assert 'Choose speed' bottom sheet did not open for stablecoin fee") { + onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() } + } + } + } + + @AllureId("5068") + @DisplayName("Gasless: switching the fee token back to the coin restores the standard fee flow") + @Test + fun checkSwitchFeeTokenBackToCoinTest() { + val nativeSymbol = "POL" + val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Open the fee token selector again via the '$tokenName' fee token") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } + } + } + step("Switch the fee token back to '$nativeTokenName'") { + onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() } + } + step("Click on 'Apply' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + } + step("Assert the network fee is now paid in '$nativeSymbol' on the summary") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() } + } + } + step("Assert 'Network fee coverage' notification is not displayed (standard fee flow)") { + onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsNotDisplayed() } + } + step("Assert 'Send' button is enabled") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + } + } + + @AllureId("5063") + @DisplayName("Gasless: insufficient stablecoin balance to cover the fee shows error and blocks send") + @Test + fun checkInsufficientBalanceForFeeTest() { + val usdcBalanceScenario = "polygon_usdc_balance" + val lowBalanceState = "LowBalance" + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(usdcBalanceScenario) + } + ).run { + step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") { + setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState) + } + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Click on 'Max' button") { + onSendScreen { maxButton.performClick() } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter the recipient and open the 'Send confirm' screen") { + enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Assert 'Not enough funds' error is displayed in the fee selector") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() } + } + } + step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") { + onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() } + } + } + } + + @AllureId("5097") + @DisplayName("Gasless: no insufficient-coin-for-fee notification is shown when gasless covers the fee") + @Test + fun checkNoInsufficientCoinNotificationWhenGaslessTest() { + val coinBalanceScenario = "polygon_coin_balance" + val zeroBalanceState = "ZeroBalance" + val feeBlockedTitlePart = getResourceString(R.string.warning_send_blocked_funds_for_fee_title, "X") + .substringAfter("X ") + .trim() + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + resetWireMockScenarioState(coinBalanceScenario) + } + ).run { + step("Set WireMock scenario '$coinBalanceScenario' to '$zeroBalanceState'") { + setWireMockScenarioState(scenarioName = coinBalanceScenario, state = zeroBalanceState) + } + step("Open 'Send' screen for '$tokenName'") { + openSendScreen(tokenName = tokenName, mockState = scenarioState) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Assert the fee defaults to '$tokenName' (gasless covers the missing coin)") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { feeBlockCurrency(tokenName).assertIsDisplayed() } + } + } + step("Assert the insufficient-coin-for-fee notification is not shown") { + onSendConfirmScreen { warningTitleContaining(feeBlockedTitlePart).assertIsNotDisplayed() } + } + step("Assert 'Send' button is enabled") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt new file mode 100644 index 0000000000..2ce8fe21c2 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/gasless/GaslessSendTest.kt @@ -0,0 +1,189 @@ +package com.tangem.tests.send.gasless + +import com.tangem.common.BaseTestCase +import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS +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.USER_TOKENS_API_SCENARIO +import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG +import com.tangem.common.extensions.clickWithAssertion +import com.tangem.common.utils.resetWireMockScenarioState +import com.tangem.common.utils.setWireMockScenarioState +import com.tangem.core.ui.R +import com.tangem.scenarios.* +import com.tangem.screens.* +import dagger.hilt.android.testing.HiltAndroidTest +import io.github.kakaocup.kakao.common.utilities.getResourceString +import io.qameta.allure.kotlin.AllureId +import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Test + +/** + * Gasless send lifecycle: signing and broadcasting a stablecoin-fee transaction (hot wallet), + * the max-amount fee reservation, and the completed gasless transaction in the token history. + */ +@HiltAndroidTest +class GaslessSendTest : BaseTestCase() { + + private val scenarioState = "PolygonUSDC" + private val tokenName = "USDC" + private val currencySymbol = "USDC" + private val nativeTokenName = "Polygon" + private val hotWalletTokensState = "PolygonUSDCHotWallet" + private val tokenAmount = "1" + + @AllureId("5069") + @DisplayName("Gasless: max amount reserves the stablecoin fee and stays sendable") + @Test + fun checkMaxAmountSendTest() { + val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title) + val feeCoverageMessagePart = getResourceString(R.string.common_network_fee_warning_content, "", "") + .substringBefore("(") + .trim() + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openGaslessSendScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + userTokensState = hotWalletTokensState, + quotesState = scenarioState, + ) + } + step("Click on 'Max' button") { + onSendScreen { maxButton.performClick() } + } + step("Click on 'Next' button") { + onSendScreen { nextButton.clickWithAssertion() } + } + step("Enter the recipient and open the 'Send confirm' screen") { + enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert 'Network fee coverage' notification title is displayed") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsDisplayed() } + } + } + step("Assert 'Network fee coverage' notification text is displayed (amount reduced by fee)") { + onSendConfirmScreen { warningMessageContaining(feeCoverageMessagePart).assertIsDisplayed() } + } + step("Assert 'Send' button is enabled (enough left for the fee)") { + onSendConfirmScreen { sendButton.assertIsEnabled() } + } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } + + @AllureId("5065") + @DisplayName("Gasless: sign and send a stablecoin transaction with the stablecoin fee") + @Test + fun checkSignAndSendGaslessTransactionTest() { + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Open the send flow for '$tokenName' on an existing hot wallet") { + openGaslessSendScreenWithHotWallet( + seedPhrase = SVS_SEED_PHRASE_12, + tokenName = tokenName, + userTokensState = hotWalletTokensState, + quotesState = scenarioState, + ) + } + step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") { + enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS) + } + step("Pay the network fee with '$tokenName' via the fee selector") { + selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + } + step("Click on 'Apply' button") { + onSendFeeSelectorBottomSheet { applyButton.performClick() } + } + step("Assert gasless fee is paid in '$currencySymbol' and 'Send' is enabled") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSendConfirmScreen { + feeBlockCurrency(currencySymbol).assertIsDisplayed() + sendButton.assertIsEnabled() + } + } + } + step("Sign, send and open the 'Transaction sent' screen") { + openSendSuccessScreenViaLongClickOnSendButton() + } + } + } + + @AllureId("5066") + @DisplayName("Gasless: completed gasless transaction is shown in token transaction history") + @Test + fun checkGaslessTransactionInHistoryTest() { + val sentAmount = "1.00" + val gaslessFeeAmount = "0.10" + val sentTitle = getResourceString(R.string.common_sent) + val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee) + + setupHooks( + additionalAfterSection = { + resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) + resetWireMockScenarioState(QUOTES_API_SCENARIO) + } + ).run { + step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState) + } + step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") { + setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState) + } + step("Open 'Main' screen") { + openMainScreen() + } + step("Synchronize addresses") { + synchronizeAddresses() + } + step("Click on token with name: '$tokenName'") { + onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } + } + step("Assert 'Token details' screen is displayed") { + onTokenDetailsScreen { screenContainer.assertIsDisplayed() } + } + step("Wait for gasless '$gaslessFeeTitle' transaction in history") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() } + } + } + step("Assert '$sentTitle' transaction is displayed") { + onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() } + } + step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") { + onTxHistoryScreen { + transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true) + transactionCurrency(sentTitle).assertTextEquals(currencySymbol) + } + } + step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") { + onTxHistoryScreen { + transactionAmount(gaslessFeeTitle).assertTextContains(gaslessFeeAmount, substring = true) + transactionCurrency(gaslessFeeTitle).assertTextEquals(currencySymbol) + } + } + step("Assert gasless '$gaslessFeeTitle' status is confirmed") { + onTxHistoryScreen { transactionConfirmedStatus(gaslessFeeTitle).assertIsDisplayed() } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt index ebbc510654..94abc606cb 100644 --- a/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt +++ b/app/src/main/java/com/tangem/tap/network/auth/DefaultAuthProvider.kt @@ -60,6 +60,7 @@ internal class DefaultAuthProvider( override fun getGaslessServiceApiKey(apiEnvironment: Provider): ProviderSuspend { return ProviderSuspend { when (apiEnvironment.invoke()) { + ApiEnvironment.MOCK, ApiEnvironment.DEV, -> environmentConfig.gaslessTxApiKeyDev ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt index 111d593100..f019a02a9d 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/GaslessTxService.kt @@ -20,11 +20,13 @@ internal class GaslessTxService( override val environmentConfigs: List = listOf( createProdEnvironment(), createDevEnvironment(), + createMockedEnvironment(), ) private fun getInitialEnvironment(): ApiEnvironment { return when (BuildConfig.BUILD_TYPE) { MOCKED_BUILD_TYPE, + -> ApiEnvironment.MOCK DEBUG_BUILD_TYPE, -> ApiEnvironment.DEV INTERNAL_BUILD_TYPE, @@ -47,6 +49,12 @@ internal class GaslessTxService( headers = createHeaders(ApiEnvironment.DEV), ) + private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig( + environment = ApiEnvironment.MOCK, + baseUrl = MOCK_BASE_URL, + headers = createHeaders(ApiEnvironment.MOCK), + ) + private fun createHeaders(environment: ApiEnvironment) = buildMap { putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values) put( @@ -60,5 +68,6 @@ internal class GaslessTxService( private companion object { private const val PROD_BASE_URL = "https://gasless.tangem.org/" private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" + private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]" } } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt index 430e41140a..98c99ba21a 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/WireMockRedirectInterceptor.kt @@ -46,6 +46,7 @@ class WireMockRedirectInterceptor : Interceptor { private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf( "deep-index.moralis.io", "solana-gateway.moralis.io", + "api.etherscan.io", ) /** diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index ac33d421ac..818e2d2650 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -318,6 +318,7 @@ internal class ProdApiConfigsManagerTest { private fun createGaslessTxServiceModel(): TestModel { val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) { MOCKED_BUILD_TYPE, + -> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]" DEBUG_BUILD_TYPE, -> ApiEnvironment.DEV to "[REDACTED_ENV_URL]" INTERNAL_BUILD_TYPE, diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt index 579b70f03b..59858ddf0e 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/transactions/TransactionItem.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextDecoration @@ -43,6 +44,7 @@ import com.tangem.core.ui.extensions.stringReference import com.tangem.core.ui.extensions.stringResourceSafe import com.tangem.core.ui.res.TangemTheme import com.tangem.core.ui.res.TangemThemePreviewRedesign +import com.tangem.core.ui.test.TransactionHistoryItemTestTags @Composable fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) { @@ -68,6 +70,7 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole val rowModifier = modifier .fillMaxWidth() .clickable(onClick = state.onClick) + .testTag(TransactionHistoryItemTestTags.ITEM) TangemRowContainer( modifier = rowModifier, @@ -82,12 +85,15 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole modifier = Modifier .layoutId(TangemRowLayoutId.HEAD) .padding(end = TangemTheme.dimens2.x3) - .size(TangemTheme.dimens2.x10), + .size(TangemTheme.dimens2.x10) + .testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix), ) TitleText( title = state.title, status = state.status, - modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP), + modifier = Modifier + .layoutId(TangemRowLayoutId.START_TOP) + .testTag(TransactionHistoryItemTestTags.TITLE), ) SubtitleText( subtitle = state.subtitle, @@ -100,13 +106,16 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole amount = state.amount, status = state.status, isBalanceHidden = isBalanceHidden, - modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP), + modifier = Modifier + .layoutId(TangemRowLayoutId.END_TOP) + .testTag(TransactionHistoryItemTestTags.AMOUNT), ) CurrencyText( symbol = state.currencySymbol, modifier = Modifier .layoutId(TangemRowLayoutId.END_BOTTOM) - .padding(top = TangemTheme.dimens2.x0_5), + .padding(top = TangemTheme.dimens2.x0_5) + .testTag(TransactionHistoryItemTestTags.CURRENCY), ) } } @@ -146,6 +155,13 @@ private val Status.iconTint: Color is Status.Failed -> TangemTheme.colors2.markers.iconRed } +private val Status.testTagSuffix: String + get() = when (this) { + is Status.Confirmed -> "CONFIRMED" + is Status.Unconfirmed -> "UNCONFIRMED" + is Status.Failed -> "FAILED" + } + // endregion // region Title / Subtitle diff --git a/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt new file mode 100644 index 0000000000..8a63a4ac21 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/test/TransactionHistoryItemTestTags.kt @@ -0,0 +1,12 @@ +package com.tangem.core.ui.test + +object TransactionHistoryItemTestTags { + const val ITEM = "TRANSACTION_HISTORY_ITEM" + const val TITLE = "TRANSACTION_HISTORY_ITEM_TITLE" + const val AMOUNT = "TRANSACTION_HISTORY_ITEM_AMOUNT" + const val CURRENCY = "TRANSACTION_HISTORY_ITEM_CURRENCY" + + /** Status is conveyed visually (icon + color), so it is exposed via a status-suffixed tag. */ + const val STATUS_PREFIX = "TRANSACTION_HISTORY_ITEM_STATUS_" + const val STATUS_CONFIRMED = STATUS_PREFIX + "CONFIRMED" +} \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/model/SendConfirmSuccessModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/model/SendConfirmSuccessModel.kt index 29d2b390d8..e2b3a973d6 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/model/SendConfirmSuccessModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/model/SendConfirmSuccessModel.kt @@ -48,7 +48,15 @@ internal class SendConfirmSuccessModel @Inject constructor( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, - ).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) -> + ).filter { (state, route) -> + // Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh + // lambdas every time, so the SendUM written back via callback.onResult is never equal to the + // previous one — without this guard the combine re-triggers itself endlessly and the success + // screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY]. + route is CommonSendRoute.ConfirmSuccess && + (state.navigationUM as? NavigationUM.Content)?.source != + CommonSendRoute.ConfirmSuccess.javaClass.simpleName + }.onEach { (state, _) -> params.callback.onResult( state.copy( navigationUM = NavigationUM.Content( diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt index 04905ad82d..80724db319 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/send/success/ui/SendConfirmSuccessContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.tangem.common.ui.amountScreen.ui.AmountBlock import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2 @@ -17,6 +18,7 @@ import com.tangem.core.ui.components.transactions.TransactionDoneTitle import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList import com.tangem.core.ui.res.TangemTheme +import com.tangem.core.ui.test.TransactionSuccessScreenTestTags import com.tangem.core.ui.utils.DateTimeFormatters import com.tangem.core.ui.utils.toPx import com.tangem.core.ui.utils.toTimeFormat @@ -25,14 +27,12 @@ import com.tangem.features.send.v2.common.ui.FeeBlockSuccess import com.tangem.features.send.v2.common.ui.state.ConfirmUM import com.tangem.features.send.v2.impl.R import com.tangem.features.send.v2.send.ui.state.SendUM -import kotlinx.coroutines.delay @Composable internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) { var isVisible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - delay(ANIMATION_DELAY) isVisible = true } @@ -50,7 +50,8 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent Box( modifier = Modifier .weight(1f) - .background(TangemTheme.colors.background.tertiary), + .background(TangemTheme.colors.background.tertiary) + .testTag(TransactionSuccessScreenTestTags.CONTAINER), ) { SuccessContent( sendUM = sendUM, @@ -111,5 +112,4 @@ private fun SuccessContent( } } -private const val ANIMATION_DELAY = 600L private val ANIMATION_OFFSET = (-40).dp \ No newline at end of file diff --git a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/model/NFTSendSuccessModel.kt b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/model/NFTSendSuccessModel.kt index 92c860fb82..11ee295245 100644 --- a/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/model/NFTSendSuccessModel.kt +++ b/features/send-v2/impl/src/main/java/com/tangem/features/send/v2/sendnft/success/model/NFTSendSuccessModel.kt @@ -46,7 +46,15 @@ internal class NFTSendSuccessModel @Inject constructor( flow = uiState, flow2 = params.currentRoute, transform = { state, route -> state to route }, - ).filter { it.second is CommonSendRoute.ConfirmSuccess }.onEach { (state, _) -> + ).filter { (state, route) -> + // Emit the success navigation exactly once. Building NavigationUM.Content here creates fresh + // lambdas every time, so the SendUM written back via callback.onResult is never equal to the + // previous one — without this guard the combine re-triggers itself endlessly and the success + // screen recomposes forever (never reaching Compose idle). See [REDACTED_TASK_KEY]. + route is CommonSendRoute.ConfirmSuccess && + (state.navigationUM as? NavigationUM.Content)?.source != + CommonSendRoute.ConfirmSuccess.javaClass.simpleName + }.onEach { (state, _) -> params.callback.onResult( state.copy( navigationUM = NavigationUM.Content(