Updated on 2026-08-14
This commit is contained in:
parent
8683516c59
commit
c81ba95db0
17 changed files with 827 additions and 10 deletions
|
|
@ -71,6 +71,12 @@ When the user asks to **port** an iOS test to Android:
|
||||||
strings inside `step(...)`.
|
strings inside `step(...)`.
|
||||||
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
||||||
click failed in the Allure report.
|
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 <thing> is displayed` / `is not displayed`.
|
- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` / `is not displayed`.
|
||||||
Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is 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).
|
`is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it).
|
||||||
|
|
|
||||||
|
|
@ -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
|
action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the
|
||||||
stale in-memory data wins.
|
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
|
## Hot wallet imports with access code
|
||||||
|
|
||||||
- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt`
|
- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
||||||
useUnmergedTree = true
|
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 {
|
fun warningIcon(message: String): KNode = child {
|
||||||
hasTestTag(NotificationTestTags.ICON)
|
hasTestTag(NotificationTestTags.ICON)
|
||||||
hasAnySibling(withText(message))
|
hasAnySibling(withText(message))
|
||||||
|
|
@ -145,6 +157,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
|
||||||
useUnmergedTree = true
|
useUnmergedTree = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun feeBlockCurrency(symbol: String): KNode = child {
|
||||||
|
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
|
||||||
|
hasAnyDescendant(withText(symbol))
|
||||||
|
useUnmergedTree = true
|
||||||
|
}
|
||||||
|
|
||||||
val refreshButton: KNode = child {
|
val refreshButton: KNode = child {
|
||||||
hasTestTag(BaseButtonTestTags.BUTTON)
|
hasTestTag(BaseButtonTestTags.BUTTON)
|
||||||
hasText(getResourceString(CoreUiR.string.warning_button_refresh))
|
hasText(getResourceString(CoreUiR.string.warning_button_refresh))
|
||||||
|
|
|
||||||
|
|
@ -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<SendFeeSelectorBottomSheetPageObject>(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)
|
||||||
|
|
@ -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<TxHistoryPageObject>(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)
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,7 @@ internal class DefaultAuthProvider(
|
||||||
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
|
override fun getGaslessServiceApiKey(apiEnvironment: Provider<ApiEnvironment>): ProviderSuspend<String> {
|
||||||
return ProviderSuspend {
|
return ProviderSuspend {
|
||||||
when (apiEnvironment.invoke()) {
|
when (apiEnvironment.invoke()) {
|
||||||
|
ApiEnvironment.MOCK,
|
||||||
ApiEnvironment.DEV,
|
ApiEnvironment.DEV,
|
||||||
-> environmentConfig.gaslessTxApiKeyDev
|
-> environmentConfig.gaslessTxApiKeyDev
|
||||||
ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey
|
ApiEnvironment.PROD -> environmentConfig.gaslessTxApiKey
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,13 @@ internal class GaslessTxService(
|
||||||
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
|
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
|
||||||
createProdEnvironment(),
|
createProdEnvironment(),
|
||||||
createDevEnvironment(),
|
createDevEnvironment(),
|
||||||
|
createMockedEnvironment(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun getInitialEnvironment(): ApiEnvironment {
|
private fun getInitialEnvironment(): ApiEnvironment {
|
||||||
return when (BuildConfig.BUILD_TYPE) {
|
return when (BuildConfig.BUILD_TYPE) {
|
||||||
MOCKED_BUILD_TYPE,
|
MOCKED_BUILD_TYPE,
|
||||||
|
-> ApiEnvironment.MOCK
|
||||||
DEBUG_BUILD_TYPE,
|
DEBUG_BUILD_TYPE,
|
||||||
-> ApiEnvironment.DEV
|
-> ApiEnvironment.DEV
|
||||||
INTERNAL_BUILD_TYPE,
|
INTERNAL_BUILD_TYPE,
|
||||||
|
|
@ -47,6 +49,12 @@ internal class GaslessTxService(
|
||||||
headers = createHeaders(ApiEnvironment.DEV),
|
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 {
|
private fun createHeaders(environment: ApiEnvironment) = buildMap {
|
||||||
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
|
putAll(RequestHeader.AppVersionPlatformHeaders(appInfoProvider).values)
|
||||||
put(
|
put(
|
||||||
|
|
@ -60,5 +68,6 @@ internal class GaslessTxService(
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
|
private const val PROD_BASE_URL = "https://gasless.tangem.org/"
|
||||||
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
|
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]"
|
||||||
|
private const val MOCK_BASE_URL = "[REDACTED_ENV_URL]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ class WireMockRedirectInterceptor : Interceptor {
|
||||||
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
|
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
|
||||||
"deep-index.moralis.io",
|
"deep-index.moralis.io",
|
||||||
"solana-gateway.moralis.io",
|
"solana-gateway.moralis.io",
|
||||||
|
"api.etherscan.io",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,7 @@ internal class ProdApiConfigsManagerTest {
|
||||||
private fun createGaslessTxServiceModel(): TestModel {
|
private fun createGaslessTxServiceModel(): TestModel {
|
||||||
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
|
val (environment, baseUrl) = when (BuildConfig.BUILD_TYPE) {
|
||||||
MOCKED_BUILD_TYPE,
|
MOCKED_BUILD_TYPE,
|
||||||
|
-> ApiEnvironment.MOCK to "[REDACTED_ENV_URL]"
|
||||||
DEBUG_BUILD_TYPE,
|
DEBUG_BUILD_TYPE,
|
||||||
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
|
-> ApiEnvironment.DEV to "[REDACTED_ENV_URL]"
|
||||||
INTERNAL_BUILD_TYPE,
|
INTERNAL_BUILD_TYPE,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.layoutId
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
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.extensions.stringResourceSafe
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
import com.tangem.core.ui.res.TangemTheme
|
||||||
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
import com.tangem.core.ui.res.TangemThemePreviewRedesign
|
||||||
|
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
fun TransactionItem(state: TransactionItemUM, isBalanceHidden: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
|
@ -68,6 +70,7 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
||||||
val rowModifier = modifier
|
val rowModifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = state.onClick)
|
.clickable(onClick = state.onClick)
|
||||||
|
.testTag(TransactionHistoryItemTestTags.ITEM)
|
||||||
|
|
||||||
TangemRowContainer(
|
TangemRowContainer(
|
||||||
modifier = rowModifier,
|
modifier = rowModifier,
|
||||||
|
|
@ -82,12 +85,15 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutId(TangemRowLayoutId.HEAD)
|
.layoutId(TangemRowLayoutId.HEAD)
|
||||||
.padding(end = TangemTheme.dimens2.x3)
|
.padding(end = TangemTheme.dimens2.x3)
|
||||||
.size(TangemTheme.dimens2.x10),
|
.size(TangemTheme.dimens2.x10)
|
||||||
|
.testTag(TransactionHistoryItemTestTags.STATUS_PREFIX + state.status.testTagSuffix),
|
||||||
)
|
)
|
||||||
TitleText(
|
TitleText(
|
||||||
title = state.title,
|
title = state.title,
|
||||||
status = state.status,
|
status = state.status,
|
||||||
modifier = Modifier.layoutId(TangemRowLayoutId.START_TOP),
|
modifier = Modifier
|
||||||
|
.layoutId(TangemRowLayoutId.START_TOP)
|
||||||
|
.testTag(TransactionHistoryItemTestTags.TITLE),
|
||||||
)
|
)
|
||||||
SubtitleText(
|
SubtitleText(
|
||||||
subtitle = state.subtitle,
|
subtitle = state.subtitle,
|
||||||
|
|
@ -100,13 +106,16 @@ private fun ContentItem(state: TransactionItemUM.Content, isBalanceHidden: Boole
|
||||||
amount = state.amount,
|
amount = state.amount,
|
||||||
status = state.status,
|
status = state.status,
|
||||||
isBalanceHidden = isBalanceHidden,
|
isBalanceHidden = isBalanceHidden,
|
||||||
modifier = Modifier.layoutId(TangemRowLayoutId.END_TOP),
|
modifier = Modifier
|
||||||
|
.layoutId(TangemRowLayoutId.END_TOP)
|
||||||
|
.testTag(TransactionHistoryItemTestTags.AMOUNT),
|
||||||
)
|
)
|
||||||
CurrencyText(
|
CurrencyText(
|
||||||
symbol = state.currencySymbol,
|
symbol = state.currencySymbol,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layoutId(TangemRowLayoutId.END_BOTTOM)
|
.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
|
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
|
// endregion
|
||||||
|
|
||||||
// region Title / Subtitle
|
// region Title / Subtitle
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,15 @@ internal class SendConfirmSuccessModel @Inject constructor(
|
||||||
flow = uiState,
|
flow = uiState,
|
||||||
flow2 = params.currentRoute,
|
flow2 = params.currentRoute,
|
||||||
transform = { state, route -> state to route },
|
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(
|
params.callback.onResult(
|
||||||
state.copy(
|
state.copy(
|
||||||
navigationUM = NavigationUM.Content(
|
navigationUM = NavigationUM.Content(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.tangem.common.ui.amountScreen.ui.AmountBlock
|
import com.tangem.common.ui.amountScreen.ui.AmountBlock
|
||||||
import com.tangem.common.ui.navigationButtons.NavigationButtonsBlockV2
|
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.resourceReference
|
||||||
import com.tangem.core.ui.extensions.wrappedList
|
import com.tangem.core.ui.extensions.wrappedList
|
||||||
import com.tangem.core.ui.res.TangemTheme
|
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.DateTimeFormatters
|
||||||
import com.tangem.core.ui.utils.toPx
|
import com.tangem.core.ui.utils.toPx
|
||||||
import com.tangem.core.ui.utils.toTimeFormat
|
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.common.ui.state.ConfirmUM
|
||||||
import com.tangem.features.send.v2.impl.R
|
import com.tangem.features.send.v2.impl.R
|
||||||
import com.tangem.features.send.v2.send.ui.state.SendUM
|
import com.tangem.features.send.v2.send.ui.state.SendUM
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) {
|
internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent: SendDestinationBlockComponent) {
|
||||||
var isVisible by remember { mutableStateOf(false) }
|
var isVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(ANIMATION_DELAY)
|
|
||||||
isVisible = true
|
isVisible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +50,8 @@ internal fun SendConfirmSuccessContent(sendUM: SendUM, destinationBlockComponent
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.background(TangemTheme.colors.background.tertiary),
|
.background(TangemTheme.colors.background.tertiary)
|
||||||
|
.testTag(TransactionSuccessScreenTestTags.CONTAINER),
|
||||||
) {
|
) {
|
||||||
SuccessContent(
|
SuccessContent(
|
||||||
sendUM = sendUM,
|
sendUM = sendUM,
|
||||||
|
|
@ -111,5 +112,4 @@ private fun SuccessContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val ANIMATION_DELAY = 600L
|
|
||||||
private val ANIMATION_OFFSET = (-40).dp
|
private val ANIMATION_OFFSET = (-40).dp
|
||||||
|
|
@ -46,7 +46,15 @@ internal class NFTSendSuccessModel @Inject constructor(
|
||||||
flow = uiState,
|
flow = uiState,
|
||||||
flow2 = params.currentRoute,
|
flow2 = params.currentRoute,
|
||||||
transform = { state, route -> state to route },
|
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(
|
params.callback.onResult(
|
||||||
state.copy(
|
state.copy(
|
||||||
navigationUM = NavigationUM.Content(
|
navigationUM = NavigationUM.Content(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue