Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-24 17:38:32 +02:00
parent bf34edf3f7
commit b1e46496b7
9 changed files with 781 additions and 2 deletions

View file

@ -145,4 +145,11 @@ Delete anything explaining WHAT a step does.
look like passing tests. look like passing tests.
- **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator - **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator
vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using
`@Ignore`, or driving WireMock scenarios. `@Ignore`, or driving WireMock scenarios. Includes **how to read the app's own log (`files/log.txt`,
not logcat)** and using the WireMock request journal to tell "mock missing" from "app never asked" —
the two techniques that find app-side root causes when the UI fails silently.
- **`reference/swap-transfer-accounts.md`** — read for any **swap, transfer-mode (same-token swap), or
multi-account ("accounts mode")** test. Covers how the accounts main screen / receive selector are
navigated, and the four mock prerequisites a token needs to be swappable/transferable (native coin for
the fee, a price quote so fiat≠0, exchange-enabled assets, and a V3 derivation card for segwit `m/84'`
coins) — each missing one fails like a locator bug but is really missing mock data.

View file

@ -154,6 +154,63 @@ Non-obvious points that bite:
Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`).
## Main-screen Markets bottom sheet swallows touch-based auto-scroll
The main screen hosts a Material3 Markets bottom sheet (nested scroll, like `PullToRefreshBox` above).
Kakao's **touch-based** auto-scroll — fired when a target is below the fold (an account card, the
"Generate addresses" button) — is handed to the sheet via nested scroll and **expands it over the
list**; the next click then lands on a market token (you end up on an unrelated token's details, e.g.
TRON). Symptoms: `autoscroll did not help` / "3 click attempts", or the test navigates somewhere random.
- **Scroll with semantics, not touch:** `onNode(SCREEN_CONTAINER).performScrollToNode(matcher)` issues a
`ScrollToIndex` semantics action that does NOT engage the sheet's nested scroll. (See
`MainScreenPageObject.scrollToAccount`.)
- **Do NOT `device.pressBack()` to collapse the expanded sheet** on the root main screen — back exits the
app. The sheet's `BackHandler` only collapses when its `currentValue == Expanded`, which races the
press, so back frequently falls through to the activity and quits to the launcher.
- **Best: avoid the trigger** — keep total fiat > 0 (a price quote for the token, see
`swap-transfer-accounts.md`) so the empty-wallet banner doesn't push the list under the sheet's peek in
the first place.
## A screen with a perpetual animation keeps Compose non-idle → idle-synced actions flake
**General principle.** Espresso/Kakao/Compose-test actions block on Compose reaching *idle* before they
act. A screen that animates forever — an auto-advancing stories/onboarding carousel, a looping shimmer, a
spinner that never stops — never goes idle, so `clickWithAssertion()`, `assertIsDisplayed()`, and Kakao
waits on it fail intermittently (`… is not displayed`, or `ComposeNotIdleException`). The fix is to **get
rid of the non-idle screen**, not to out-wait it.
**Polling the animated node does NOT fix it** — two attempts that look right but aren't:
- `composeTestRule.waitUntilAtLeastOneExists(hasTestTag(TAG), …)` polls the **merged** tree (it has no
`useUnmergedTree` option). If the target is a `clickable` element inside a `mergeDescendants` container
(common for tap-to-advance surfaces), its tag lives **only in the unmerged tree** → the wait never
matches → full-timeout on every run.
- `waitUntil { runCatching { onScreen { node.assertIsDisplayed() } }.isSuccess }` reads the unmerged tree
(good) but Kakao's `assertIsDisplayed` *itself* blocks on idle, and the screen never idles → each probe
hangs → the outer `waitUntil` times out too.
**Fix: remove the animated screen at the source.** Most such screens are gated by a feature toggle or a
mock response — flip it off so the screen never renders, instead of interacting with it. When the toggle
is server-driven, set it **before app launch** (`additionalBeforeAppLaunchSection`, which runs before
`ActivityScenario.launch`) since the config is usually fetched at startup; setting it mid-test is too late.
**Concrete instance (verify against current source — names drift):** the first-time *swap stories* are
controlled by the WireMock scenario `stories_first_time_swap_v2`. Its `Error` state returns 500, so the
screen never shows, and `openSwapScreen(…, storiesExist = false)` skips the close entirely:
```kotlin
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState("stories_first_time_swap_v2", "Error") },
additionalAfterSection = { resetWireMockScenarioState("stories_first_time_swap_v2") },
).run {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
```
`SwapStoriesTest` uses this for every flow that isn't specifically testing stories. Only keep
`storiesExist = true` + `clickWithAssertion()` when the animated screen itself is the subject under test
(then accept that you're synchronizing against an animation and budget a longer, existence-based wait).
## Decompose model lifecycle vs. data refresh ## Decompose model lifecycle vs. data refresh
Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning

View file

@ -100,6 +100,46 @@ curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(
(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the (harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the
local instance is missing. local instance is missing.
## The app's own log lives in `files/log.txt`, NOT logcat
The mocked build routes `TangemLogger` to a **file** via `FileLogWriter`, so `adb logcat | grep …`
finds nothing of the app's own logs. When a screen fails silently — fee shows "—", a banner never
appears, an action button stays disabled — *and* WireMock shows everything matched, the real reason is
almost always in the app log:
```bash
adb exec-out run-as com.tangem.wallet.mocked cat files/log.txt | grep -iE "loadFee|getFee|No native|Error|DataError"
```
This is the single fastest way to find app-side root causes. It's what pinpointed a transfer-fee failure
to `loadFee[transfer]: DataError(... IllegalStateException: No native currency found ...)` — i.e. a
missing native coin in the mock, invisible from the UI and from the WireMock journal alone.
## WireMock journal: "mock missing" vs "the app never asked"
`/__admin/requests/unmatched` finds *missing* mappings. But when a feature silently doesn't happen (a fee
that never computes, a banner that never shows), also inspect the **full** request log — the app may not
be issuing the request at all (an app-side data gate), which is a different problem than a missing mock
and is NOT fixable by adding mappings:
```bash
curl -s "http://localhost:8081/__admin/requests?limit=500" \
| jq -r '.requests[].request | "\(.method) \(.url)"' | sort -u
# For RPC providers, also break down by method:
curl -s "http://localhost:8081/__admin/requests?limit=500" \
| jq -r '.requests[].request.body' | grep -oE '"method":"[^"]+"' | sort | uniq -c
```
`unmatched=0` **and** the expected request absent → the app never asked (data/state gate, e.g. a coin
missing from the portfolio) → fix the mock *data* or app, not the mappings.
## "UiAutomationService already registered" = back-to-back runs; retry
Rapid consecutive `am instrument` invocations sometimes fail instantly with
`IllegalStateException: UiAutomationService … already registered!`. It's an instrumentation-teardown
race between runs, not a test failure — just retry. (The orchestrator/CI spaces runs out and avoids it.)
When scripting many manual runs, retry-on-this-string rather than counting it as a failure.
## Classify the result — Allure noise vs. real failure ## Classify the result — Allure noise vs. real failure
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist → After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →

View file

@ -0,0 +1,80 @@
# Swap / transfer-mode / accounts-mode test setup
Hard-won prerequisites for swap, transfer-mode (same-token swap), and multi-account ("accounts mode")
tests. Each missing item below produces a state that *looks* like a locator/test bug but is actually
missing mock data — you'll burn hours on the UI before realizing the data never arrived.
> **Read this as symptom → where-to-look, not as a recipe.** The durable part of each item is the
> *symptom* and the *source-of-truth file/class* it names. The concrete mock-state names
> (`USDTHotWalletSvS`, `BitcoinExchangeEnabled`, …), mock classes (`Wallet2WithDerivationsMockContent`),
> string resources, and even "a token needs its native coin for the fee" are **a snapshot that will
> drift** — verify each against the cited source before trusting it. The change-proof skills are the
> *symptom → category* mapping here plus the two diagnostics in `running-and-debugging.md` (the app's
> `files/log.txt` and the WireMock request journal), which surface the *current* cause regardless of
> renames. If the specifics below stop matching, don't patch around them — re-derive from source and
> update this doc.
## Accounts mode: the main screen shows ACCOUNT cards, not a token list
With a two-accounts mock (`user_tokens_api=TwoAccountsSame…`, served via the `/v1/wallets/{id}/accounts`
endpoint), the main screen renders `MAIN_SCREEN_ACCOUNT_LIST_ITEM` cards ("Account 1", "Account 2"), NOT a
flat token list. `tokenWithTitleAndAddress("Bitcoin")` finds nothing. To reach a token: expand the
account, then click the token inside it.
```kotlin
onMainScreen { scrollToAccount("Account 1") } // semantics scroll (see traps)
onMainScreen { findAccountSectionByName("Account 1").clickWithAssertion() } // expand the account
onMainScreen { findTokenInAnyAccountByName("Bitcoin").clickWithAssertion() } // token inside the account
```
The Swap **receive** selector also groups assets by account and renders the *other* account **collapsed**
expand its group header before tapping the identical token (`tokenWithName("Account 2")` then
`tokenWithName("Bitcoin")`). Reuse `openSwapInTransferMode(token, fromAccountName, toAccountName)` in
`SwapScenarios.kt`, which encapsulates this.
## Four mock prerequisites for a token to be transferable / swappable
A transfer or swap depends on ALL of these. Each missing one fails differently:
1. **Native coin present — else the fee never computes.** To compute an ERC20 token's transfer/send fee
the wallet must contain the token's NATIVE coin (e.g. Ethereum for USDT-on-ethereum). If the
user-tokens mock lists only the token, `GetFeeUseCase``getFee` raises
`IllegalStateException: No native currency found` → fee stays "—" → the fee-warning banner never shows
and the action button stays disabled. **Fix: add the native coin to each account** in the mock
response (`{"name":"Ethereum","symbol":"ETH","networkId":"ethereum","decimals":18,"id":"ethereum",
"derivationPath":"<account's EVM path>"}`).
2. **A price quote — else fiat = $0 → empty-wallet banner.** A token with a balance but no price → total
fiat $0 → the "Get your first crypto" banner appears and pushes the account list down **under the
Markets sheet** (then account navigation can't reach the cards). **Fix: set the quotes scenario that
prices the token** (`quotes_api=Solana`, `quotes_api=USDTHotWalletSvS`, …).
3. **Exchange-enabled — else the token-details Swap button is disabled.** Set `express_api_assets` to a
state marking the token `exchangeAvailable=true` (e.g. `BitcoinExchangeEnabled`).
4. **Matching derivation style — else `isCustom` → Swap disabled.** The default `Wallet` mock is
derivation-style **V2**; for a segwit/BIP-84 coin the user-tokens stub sends `m/84'/…`, which V2
resolves as a *custom* path → `CryptoCurrency.isCustom == true``CommonActionsFactory.createSwapAction`
returns `CustomToken` → Swap button disabled. **Fix: scan a V3 card**
`openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)` — so `m/84'` is the
card's default path and the coin isn't custom.
Account-2 derivations: `Wallet2WithDerivationsMockContent.derivationTaskResponse` re-keys
`WalletMockContent`'s entries, so a missing per-account path (e.g. Bitcoin account-2 `m/84'/0'/1'/0/0`)
must be added to `WalletMockContent`'s `derivationTaskResponse` for the receive account to resolve.
## "Insufficient funds" vs "insufficient fee" are DIFFERENT banners
Don't confuse them when porting (iOS often asserts "any notification", which masks which one fired):
- **amount > token balance**`swapping_insufficient_funds` ("Insufficient funds … Reduce the amount") —
shows immediately, no fee needed.
- **native coin < fee**`warning_send_blocked_funds_for_fee_title` ("Insufficient `<Coin>` to cover
network fee", from `NotificationUM.Error.TokenExceedsBalance`). This fires only once a fee is **computed**
(`fee > 0`, via `GetBalanceNotEnoughForFeeWarningUseCase`), so prerequisite #1 is mandatory and the
native balance must be set to **zero/insufficient** (`eth_network_balance=EmptyAnyId`) *with the coin
present*.
## Why an iOS transfer test may assert something the screen can't show
iOS `waitForNotificationShown()` checks "any `notificationTitle` exists", so the iOS test goes green on a
generic "Error" banner when the fee fails to load — it does **not** prove the intended banner rendered.
Re-derive the correct Android notification string from production source and assert *that* specific banner;
treat the iOS assertion's leniency as a hint, not a spec.

View file

@ -11,6 +11,7 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertVisibility import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWhenEnabled import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText import com.tangem.common.extensions.extractText
@ -18,6 +19,7 @@ import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags
import com.tangem.screens.* import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR import com.tangem.common.ui.R as CommonUiR
@ -260,6 +262,56 @@ fun BaseTestCase.checkSwapWarning(
} }
} }
/** Opens Swap for [tokenName] in [fromAccountName] and picks it again in [toAccountName] to enter Transfer mode; needs a two-accounts-same-token mock. */
fun BaseTestCase.openSwapInTransferMode(
tokenName: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
mockContent: MockContent? = null,
) {
step("Open 'Main' screen") {
openMainScreen(mockContent = mockContent)
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Scroll '$fromAccountName' into view (semantics, not touch — avoids the Markets sheet)") {
onMainScreen { scrollToAccount(fromAccountName) }
}
step("Expand account '$fromAccountName' and reveal token '$tokenName'") {
onMainScreen {
findAccountSectionByName(fromAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(tokenName).assertIsDisplayed() }
},
)
}
}
step("Click on token with name: '$tokenName'") {
onMainScreen { findTokenInAnyAccountByName(tokenName).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */
fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Expand account '$receiveAccountName' in receive selector") {
onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
fun BaseTestCase.chooseReceiveToken(tokenName: String) { fun BaseTestCase.chooseReceiveToken(tokenName: String) {
step("Click on 'Choose token' button") { step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() } onSwapTokenScreen { chooseTokenButton.performClick() }

View file

@ -102,6 +102,15 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti
} }
} }
/** Scrolls to [accountName] via ScrollToIndex semantics, not a touch swipe — a bottom-edge drag is stolen by the Markets sheet's nested scroll. */
@OptIn(ExperimentalTestApi::class)
fun scrollToAccount(accountName: String) {
semanticsProvider.onNode(withTestTag(MainScreenTestTags.SCREEN_CONTAINER))
.performScrollToNode(
withTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM) and hasAnyDescendant(withText(accountName)),
)
}
val restoringProgressText: KNode = child { val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT) hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true useUnmergedTree = true

View file

@ -130,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
fun insufficientFeeForTransferNotificationTitle(feeCoinName: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCoinName))
useUnmergedTree = true
}
fun warningTitle(title: String): KNode = child { fun warningTitle(title: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE) hasTestTag(NotificationTestTags.TITLE)
hasText(title) hasText(title)
@ -158,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(R.string.common_swap)) hasText(getResourceString(R.string.common_swap))
} }
val transferButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.swapping_transfer_action))
}
val transferTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_transfer))
useUnmergedTree = true
}
// PercentPill testTag is the PredefinedPercentAmount enum name; MAX == "MAX".
val maxAmountButton: KNode = child {
hasTestTag("MAX")
useUnmergedTree = true
}
val youSwapBlock: KNode = child { val youSwapBlock: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER) hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER)
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2))) hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2)))

View file

@ -0,0 +1,495 @@
package com.tangem.tests.transfer
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithDerivationsMockContent
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class AppTransfersTest : BaseTestCase() {
private val ethCallScenario = "eth_call_api"
private val ethBalanceScenario = "eth_network_balance"
private val started = "Started"
// Disable the first-time-swap stories (500 → not shown); their auto-advancing animation keeps Compose non-idle and flakes the close.
private val storiesScenario = "stories_first_time_swap_v2"
private val storiesErrorState = "Error"
private fun BaseTestCase.assertTransferReady() {
step("Assert action button label is 'Transfer'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
}
}
step("Assert provider block is not displayed") {
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
}
private fun BaseTestCase.inputAmount(amount: String) {
step("Input amount '$amount'") {
waitForIdle()
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(amount)
}
}
}
private fun BaseTestCase.waitForFeeDisplayed() {
step("Assert fee amount is displayed") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
}
}
}
@AllureId("9838")
@DisplayName("App transfers: identical pair switches to Transfer mode")
@Test
fun identicalPairSwitchesToTransferModeTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(amount)
assertTransferReady()
waitForFeeDisplayed()
}
}
@AllureId("9843")
@DisplayName("App transfers: zero amount keeps Transfer button disabled")
@Test
fun zeroAmountKeepsTransferButtonDisabledTest() {
val token = "Ethereum"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Assert provider block is not displayed") {
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
}
step("Assert 'Transfer' button is disabled") {
onSwapTokenScreen { transferButton.assertIsNotEnabled() }
}
}
}
@AllureId("9992")
@DisplayName("App transfers: reversing tokens keeps Transfer mode")
@Test
fun reversingTokensKeepsTransferModeTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(amount)
waitForFeeDisplayed()
step("Click on 'Swap tokens' (reverse) button") {
onSwapTokenScreen { replaceTokensButton.performClick() }
}
assertTransferReady()
}
}
@AllureId("9847")
@DisplayName("App transfers: Max amount keeps Transfer enabled and subtracts fee")
@Test
fun maxAmountFractionSubtractsFeeTest() {
val token = "Ethereum"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
step("Focus amount field to reveal predefined amount buttons") {
waitForIdle()
onSwapTokenScreen { textInput.clickWithAssertion() }
}
step("Click on 'Max' amount button") {
onSwapTokenScreen { maxAmountButton.performClick() }
}
waitForFeeDisplayed()
assertTransferReady()
step("Assert 'Transfer' button is enabled") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { transferButton.assertIsEnabled() } }.isSuccess
}
}
}
}
@AllureId("9844")
@DisplayName("App transfers: amount above balance disables Transfer")
@Test
fun amountAboveBalanceDisablesTransferTest() {
val token = "Ethereum"
val aboveBalanceAmount = "100"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(aboveBalanceAmount)
// Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state.
step("Assert 'Insufficient funds' is displayed") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } }.isSuccess
}
}
}
}
@AllureId("10003")
@DisplayName("App transfers: EVM network fee speed options")
@Test
fun evmNetworkFeeSpeedOptionsTest() {
val token = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameToken"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(amount)
waitForFeeDisplayed()
assertTransferReady()
var marketFee = ""
step("Read displayed 'Market' fee amount") {
onSwapTokenScreen { marketFee = feeAmount.extractText() }
}
step("Open 'Network fee' selector via 'Select fee' icon") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { selectFeeIcon.performClick() } }
runCatching { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } }.isSuccess
}
}
step("Click on 'Fast' fee option") {
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
}
step("Assert fee amount changed from Market fee") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSwapTokenScreen { feeAmount.assertIsDisplayed() }
}.isSuccess && onSwapTokenScreenFeeDiffers(marketFee)
}
}
}
}
@AllureId("10002")
@DisplayName("App transfers: UTXO network fee")
@Test
fun utxoNetworkFeeTest() {
val token = "Bitcoin"
val amount = "0.001"
val userTokensState = "TwoAccountsSameBitcoin"
val bitcoinUtxoScenario = "bitcoin_utxo"
val bitcoinUtxoState = "BalanceAnyAddress"
val assetsScenario = "express_api_assets"
val assetsBitcoinState = "BitcoinExchangeEnabled"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(bitcoinUtxoScenario)
resetWireMockScenarioState(assetsScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$bitcoinUtxoScenario' to state: '$bitcoinUtxoState'") {
setWireMockScenarioState(scenarioName = bitcoinUtxoScenario, state = bitcoinUtxoState)
}
// Bitcoin swap must be exchange-enabled or the token-details Swap button stays disabled.
step("Set WireMock scenario: '$assetsScenario' to state: '$assetsBitcoinState'") {
setWireMockScenarioState(scenarioName = assetsScenario, state = assetsBitcoinState)
}
// V3 card: Bitcoin's default path is m/84' (matches the stub) so the coin isn't custom — else the Swap button stays disabled.
step("Open Swap in Transfer mode for '$token'") {
openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)
}
inputAmount(amount)
assertTransferReady()
waitForFeeDisplayed()
}
}
@AllureId("10004")
@DisplayName("App transfers: Solana network fee")
@Test
fun solanaNetworkFeeTest() {
val token = "Solana"
val amount = "0.001"
val userTokensState = "TwoAccountsSameSolana"
val solanaBalanceScenario = "solana_balance"
val quotesSolanaState = "Solana"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(solanaBalanceScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$solanaBalanceScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = solanaBalanceScenario, state = started)
}
// Non-zero SOL price keeps total fiat > 0 so the empty-wallet banner doesn't push the account list under the Markets sheet.
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesSolanaState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesSolanaState)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(amount)
assertTransferReady()
waitForFeeDisplayed()
}
}
@AllureId("9845")
@DisplayName("App transfers: insufficient native coin for fee disables Transfer")
@Test
fun insufficientNativeCoinForFeeDisablesTransferTest() {
val token = "Tether"
val feeCoinName = "Ethereum"
val amount = "0.001"
val userTokensState = "TwoAccountsSameUsdt"
// Zero native ETH (coin present in the mock so the fee still estimates) → fee exceeds balance.
val ethBalanceState = "EmptyAnyId"
val quotesUsdtState = "USDTHotWalletSvS"
val feeHistoryScenario = "eth_fee_history"
val estimateGasScenario = "eth_estimate_gas"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = {
resetWireMockScenarioState(storiesScenario)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(ethCallScenario)
resetWireMockScenarioState(ethBalanceScenario)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(feeHistoryScenario)
resetWireMockScenarioState(estimateGasScenario)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario: '$ethCallScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = ethCallScenario, state = started)
}
step("Set WireMock scenario: '$ethBalanceScenario' to state: '$ethBalanceState'") {
setWireMockScenarioState(scenarioName = ethBalanceScenario, state = ethBalanceState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesUsdtState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesUsdtState)
}
step("Set WireMock scenario: '$feeHistoryScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = feeHistoryScenario, state = started)
}
step("Set WireMock scenario: '$estimateGasScenario' to state: '$started'") {
setWireMockScenarioState(scenarioName = estimateGasScenario, state = started)
}
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
inputAmount(amount)
step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSwapTokenScreen {
insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed()
}
}.isSuccess
}
}
}
}
@AllureId("9990")
@DisplayName("App transfers: search filters receive token list")
@Test
fun searchFiltersReceiveTokenListTest() {
val sourceToken = "Polygon"
val ethereumToken = "Ethereum"
val polygonReceiveName = "POL (ex-MATIC)"
val noMatchQuery = "f"
val polygonQuery = "pol"
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState(storiesScenario, storiesErrorState) },
additionalAfterSection = { resetWireMockScenarioState(storiesScenario) },
).run {
step("Open 'Main' screen") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Click on token with name: '$sourceToken'") {
onMainScreen { tokenWithTitleAndAddress(sourceToken).clickWithAssertion() }
}
step("Open 'Swap' screen") { openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) }
step("Open receive token selector") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Type '$noMatchQuery' in search field") {
onSwapSelectTokenScreen {
searchBarBlock.performClick()
searchBarBlock.performTextInput(noMatchQuery)
}
}
step("Assert '$ethereumToken' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() }
}
step("Assert '$polygonReceiveName' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsNotDisplayed() }
}
step("Replace search text with '$polygonQuery'") {
onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) }
}
step("Assert '$polygonReceiveName' is displayed") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } }.isSuccess
}
}
step("Assert '$ethereumToken' is not displayed") {
onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() }
}
}
}
private fun BaseTestCase.onSwapTokenScreenFeeDiffers(previousFee: String): Boolean {
var current = ""
onSwapTokenScreen { current = feeAmount.extractText() }
return current.isNotEmpty() && current != previousFee
}
}

View file

@ -172,7 +172,16 @@ object WalletMockContent : MockContent {
remainingSignatures = null, remainingSignatures = null,
index = 1, index = 1,
hasBackup = false, hasBackup = false,
derivedKeys = emptyMap(), derivedKeys = mapOf(
DerivationPath("m/44'/501'/0'") to ExtendedPublicKey( // Solana (account 1)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
DerivationPath("m/44'/501'/1'") to ExtendedPublicKey( // Solana (account 2)
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
),
),
extendedPublicKey = ExtendedPublicKey( extendedPublicKey = ExtendedPublicKey(
publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109), publicKey = byteArrayOf(-65, -53, -62, -12, -57, -32, -38, -9, -128, -52, -83, -61, 73, 39, 41, 15, -74, -97, 38, 52, -101, 63, 74, -56, -20, 15, 57, -127, 114, -93, -17, -109),
chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6), chainCode = byteArrayOf(-81, 15, 125, 28, -115, -22, 87, 81, 87, -123, -25, -74, 86, 2, 1, 110, -115, 65, -110, 63, -64, 83, -93, -97, -104, 123, 12, -26, 94, 27, 84, -6),
@ -218,6 +227,13 @@ object WalletMockContent : MockContent {
parentFingerprint = byteArrayOf(0, 0, 0, 0), parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0, childNumber = 0,
), ),
DerivationPath("m/84'/0'/1'/0/0") to ExtendedPublicKey( // btc (account 2)
publicKey = byteArrayOf(3, 45, 58, -110, -52, -51, -83, -4, -45, -118, 119, 37, 123, -17, 66, -83, 61, -106, 115, 47, 121, 66, 84, -122, -57, -45, 7, -79, 70, -13, 28, -125, -52),
chainCode = byteArrayOf(93, 51, 52, -66, -39, -38, 34, -84, 50, 1, -127, -20, 80, -20, -30, -72, 2, 1, -78, -81, -17, 51, -52, -25, 12, 108, 50, 89, -66, 18, 65, 70),
depth = 0,
parentFingerprint = byteArrayOf(0, 0, 0, 0),
childNumber = 0,
),
DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth DerivationPath("m/44'/60'/0'/0/0") to ExtendedPublicKey( // eth
publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67), publicKey = byteArrayOf(2, 34, 6, 119, -106, 5, -119, 111, -22, 8, 23, -108, -72, -56, 6, 77, -17, -61, -101, -85, 16, 28, 18, 3, -3, -89, -81, -108, 48, -7, -86, -82, -67),
chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109), chainCode = byteArrayOf(-75, 55, 107, -106, -37, -81, -15, 72, -102, 94, 55, -39, 9, -112, 1, 90, -50, 103, 53, 120, -92, -36, -85, -39, -65, 1, 88, 46, 92, 104, -13, -109),