Updated on 2026-08-14
This commit is contained in:
commit
57a0201740
8 changed files with 660 additions and 9 deletions
|
|
@ -74,6 +74,10 @@ When the user asks to **port** an iOS test to Android:
|
|||
strings inside `step(...)`.
|
||||
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
||||
click failed in the Allure report.
|
||||
- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests
|
||||
(e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a
|
||||
private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a
|
||||
`step(...)` like any scenario.
|
||||
- **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
|
||||
|
|
@ -121,13 +125,20 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
|
|||
|
||||
### Waits and synchronization
|
||||
|
||||
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop). Use:
|
||||
- `composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMillis)` — wait for one thing to appear.
|
||||
- `composeTestRule.waitUntil(timeout) { runCatching { someAssertion() }.isSuccess }` — wait until an
|
||||
action no longer throws.
|
||||
- `composeTestRule.waitUntil(timeout) { matcherA exists || matcherB exists }` — the either/or case.
|
||||
- **`flakySafely(timeout)`** (Kaspresso) is reachable only from `TestCase` subclasses, NOT from
|
||||
extension functions on `BaseTestCase`. In extension code use the `waitUntil` variants above.
|
||||
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even
|
||||
if a bot reviewer suggests one.
|
||||
- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of
|
||||
uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`.
|
||||
- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not
|
||||
your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI
|
||||
isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start
|
||||
timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or
|
||||
that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data
|
||||
-memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean
|
||||
10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot.
|
||||
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the
|
||||
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for
|
||||
appearance, `{ a exists || b exists }` for either/or).
|
||||
|
||||
### Comment hygiene
|
||||
|
||||
|
|
@ -145,4 +156,5 @@ Delete anything explaining WHAT a step does.
|
|||
look like passing tests.
|
||||
- **`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
|
||||
`@Ignore`, or driving WireMock scenarios.
|
||||
`@Ignore`, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails
|
||||
silently (the app log in `files/log.txt`, and the WireMock journal).
|
||||
|
|
@ -154,6 +154,34 @@ Non-obvious points that bite:
|
|||
|
||||
Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`).
|
||||
|
||||
## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet)
|
||||
|
||||
When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's
|
||||
**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead —
|
||||
expanding the sheet over the content, so the next click lands on the wrong element.
|
||||
|
||||
- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a
|
||||
`ScrollToIndex` action that does NOT engage nested scroll.
|
||||
- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires
|
||||
when already expanded, races the press, and back often falls through and quits the app.
|
||||
|
||||
## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake
|
||||
|
||||
Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an
|
||||
auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so
|
||||
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
|
||||
`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a
|
||||
slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not
|
||||
by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator.
|
||||
|
||||
For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most
|
||||
are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's
|
||||
server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test.
|
||||
(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with
|
||||
`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree
|
||||
(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists
|
||||
only in the *unmerged* tree — will never match it; poll through the page object instead.
|
||||
|
||||
## Decompose model lifecycle vs. data refresh
|
||||
|
||||
Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning
|
||||
|
|
|
|||
|
|
@ -100,6 +100,29 @@ 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
|
||||
local instance is missing.
|
||||
|
||||
## When the UI fails silently, the cause is usually app-side — two places to look
|
||||
|
||||
A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays
|
||||
disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it:
|
||||
|
||||
- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a
|
||||
file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced
|
||||
`IllegalStateException: No native currency found` → a native coin missing from the mock):
|
||||
```bash
|
||||
adb exec-out run-as <pkg> cat files/log.txt | grep -iE "Error|Exception|<feature>"
|
||||
```
|
||||
- **The WireMock journal separates "mock missing" from "app never asked"** —
|
||||
`/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request
|
||||
is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) →
|
||||
fix the mock data or the app, not the mappings.
|
||||
|
||||
## "UiAutomationService already registered" — retry, it's not a failure
|
||||
|
||||
Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already
|
||||
registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by
|
||||
spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw
|
||||
`am instrument`.)
|
||||
|
||||
## Classify the result — Allure noise vs. real failure
|
||||
|
||||
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →
|
||||
|
|
|
|||
|
|
@ -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_VERY_LONG
|
||||
import com.tangem.common.extensions.assertVisibility
|
||||
import com.tangem.common.extensions.clickAndWaitFor
|
||||
import com.tangem.common.extensions.clickWhenEnabled
|
||||
import com.tangem.common.extensions.clickWithAssertion
|
||||
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.HotWalletAccessCodeTestTags
|
||||
import com.tangem.screens.*
|
||||
import com.tangem.tap.domain.sdk.mocks.MockContent
|
||||
import io.github.kakaocup.kakao.common.utilities.getResourceString
|
||||
import io.qameta.allure.kotlin.Allure.step
|
||||
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) {
|
||||
step("Click on 'Choose token' button") {
|
||||
onSwapTokenScreen { chooseTokenButton.performClick() }
|
||||
|
|
@ -383,4 +435,35 @@ enum class FeeType {
|
|||
Fast
|
||||
}
|
||||
|
||||
fun BaseTestCase.inputAmount(amount: String) {
|
||||
// No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle.
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess
|
||||
}
|
||||
onSwapTokenScreen {
|
||||
textInput.clickWithAssertion()
|
||||
textInput.performTextReplacement(amount)
|
||||
}
|
||||
}
|
||||
|
||||
// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase.
|
||||
fun BaseTestCase.assertTransferReady() {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
|
||||
}
|
||||
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
|
||||
}
|
||||
|
||||
fun BaseTestCase.waitForFeeDisplayed() {
|
||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean {
|
||||
var current = ""
|
||||
onSwapTokenScreen { current = feeAmount.extractText() }
|
||||
return current.isNotEmpty() && current != previousFee
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
|
||||
useUnmergedTree = true
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
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 {
|
||||
hasTestTag(NotificationTestTags.TITLE)
|
||||
hasText(title)
|
||||
|
|
@ -158,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
|
|||
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 {
|
||||
hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER)
|
||||
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2)))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,457 @@
|
|||
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"
|
||||
|
||||
@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) }
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||
step("Assert network fee is displayed") { 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) }
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||
step("Click on 'Swap tokens' (reverse) button") {
|
||||
onSwapTokenScreen { replaceTokensButton.performClick() }
|
||||
}
|
||||
step("Assert Transfer mode is ready") { 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() }
|
||||
}
|
||||
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||
step("Assert 'Transfer' button is enabled") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { transferButton.assertIsEnabled() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
step("Enter amount '$aboveBalanceAmount'") { 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") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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) }
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||
|
||||
var marketFee = ""
|
||||
step("Read displayed 'Market' fee amount") {
|
||||
onSwapTokenScreen { marketFee = feeAmount.extractText() }
|
||||
}
|
||||
step("Open 'Network fee' selector via 'Select fee' icon") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { selectFeeIcon.performClick() }
|
||||
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Click on 'Fast' fee option") {
|
||||
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
|
||||
}
|
||||
step("Assert fee amount changed from Market fee") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen { feeAmount.assertIsDisplayed() }
|
||||
check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$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)
|
||||
}
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||
step("Assert network fee is displayed") { 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) }
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||
step("Assert network fee is displayed") { 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) }
|
||||
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||
step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapTokenScreen {
|
||||
insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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") {
|
||||
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||
onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() }
|
||||
}
|
||||
}
|
||||
step("Assert '$ethereumToken' is not displayed") {
|
||||
onSwapSelectTokenScreen { tokenWithName(ethereumToken).assertIsNotDisplayed() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +172,16 @@ object WalletMockContent : MockContent {
|
|||
remainingSignatures = null,
|
||||
index = 1,
|
||||
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(
|
||||
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),
|
||||
|
|
@ -218,6 +227,13 @@ object WalletMockContent : MockContent {
|
|||
parentFingerprint = byteArrayOf(0, 0, 0, 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
|
||||
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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue