From b1e46496b71d8471bb5ebf5e43b952e9939ea44a Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:38:32 +0200 Subject: [PATCH 1/3] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 9 +- .../write-ui-test/reference/compose-traps.md | 57 ++ .../reference/running-and-debugging.md | 40 ++ .../reference/swap-transfer-accounts.md | 80 +++ .../com/tangem/scenarios/SwapScenarios.kt | 52 ++ .../tangem/screens/MainScreenPageObject.kt | 9 + .../com/tangem/screens/SwapTokenPageObject.kt | 23 + .../tangem/tests/transfer/AppTransfersTest.kt | 495 ++++++++++++++++++ .../sdk/mocks/content/WalletMockContent.kt | 18 +- 9 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/write-ui-test/reference/swap-transfer-accounts.md create mode 100644 app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index acb6f6d5da..c0254bd437 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -145,4 +145,11 @@ 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. \ No newline at end of file + `@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. \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 0458caa898..f5469d551e 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -154,6 +154,63 @@ Non-obvious points that bite: 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 Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md index 5698b4e87f..d8bc28f591 100644 --- a/.claude/skills/write-ui-test/reference/running-and-debugging.md +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -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 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 After `pm clear`, `/data/user/0//files/original_screenshots` doesn't exist → diff --git a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md new file mode 100644 index 0000000000..8387e1a99d --- /dev/null +++ b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md @@ -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":""}`). +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 `` 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. \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index cd9cfb4dd6..9430532499 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -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() } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index 7ce81ac85b..8e83fc81ea 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -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 diff --git a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt index a0374130c3..3bdeff493c 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/SwapTokenPageObject.kt @@ -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))) diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt new file mode 100644 index 0000000000..4e87d66fd5 --- /dev/null +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt index e6d872be54..78c6b52b7b 100644 --- a/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt +++ b/app/src/main/java/com/tangem/tap/domain/sdk/mocks/content/WalletMockContent.kt @@ -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), From d0e80545355ef30e5421b128f369a6692041c301 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 17:55:18 +0200 Subject: [PATCH 2/3] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 10 +-- .../write-ui-test/reference/compose-traps.md | 72 +++++------------ .../reference/running-and-debugging.md | 53 +++++------- .../reference/swap-transfer-accounts.md | 80 ------------------- 4 files changed, 41 insertions(+), 174 deletions(-) delete mode 100644 .claude/skills/write-ui-test/reference/swap-transfer-accounts.md diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index c0254bd437..f35d144d56 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -145,11 +145,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. 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. \ No newline at end of file + `@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). \ No newline at end of file diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index f5469d551e..817f5c019a 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -154,62 +154,32 @@ Non-obvious points that bite: Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`). -## Main-screen Markets bottom sheet swallows touch-based auto-scroll +## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet) -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. +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(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. +- **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 screen with a perpetual animation keeps Compose non-idle → idle-synced actions flake +## A perpetually animating screen 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. +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`). **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 +stories are disabled via their WireMock scenario, then opened with `storiesExist = false`.) -**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). +**Polling the animated node does NOT rescue it** — two false fixes: +- `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree (no `useUnmergedTree` option); a + `clickable` node inside a `mergeDescendants` container exists only in the *unmerged* tree → never matches. +- `waitUntil { runCatching { node.assertIsDisplayed() }.isSuccess }` reads the unmerged tree but + `assertIsDisplayed` itself blocks on idle, which never comes → the outer wait times out too. ## Decompose model lifecycle vs. data refresh diff --git a/.claude/skills/write-ui-test/reference/running-and-debugging.md b/.claude/skills/write-ui-test/reference/running-and-debugging.md index d8bc28f591..8f73aaa83c 100644 --- a/.claude/skills/write-ui-test/reference/running-and-debugging.md +++ b/.claude/skills/write-ui-test/reference/running-and-debugging.md @@ -100,45 +100,28 @@ 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. -## The app's own log lives in `files/log.txt`, NOT logcat +## When the UI fails silently, the cause is usually app-side — two places to look -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: +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: -```bash -adb exec-out run-as com.tangem.wallet.mocked cat files/log.txt | grep -iE "loadFee|getFee|No native|Error|DataError" -``` +- **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 cat files/log.txt | grep -iE "Error|Exception|" + ``` +- **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. -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. +## "UiAutomationService already registered" — retry, it's not a failure -## 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. +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 diff --git a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md b/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md deleted file mode 100644 index 8387e1a99d..0000000000 --- a/.claude/skills/write-ui-test/reference/swap-transfer-accounts.md +++ /dev/null @@ -1,80 +0,0 @@ -# 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":""}`). -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 `` 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. \ No newline at end of file From 0ce9be5b08bcad15e707c2292ca2ab9951590848 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 23:39:09 +0200 Subject: [PATCH 3/3] Updated on 2026-08-14 --- .claude/skills/write-ui-test/SKILL.md | 25 ++-- .../write-ui-test/reference/compose-traps.md | 19 +-- .../com/tangem/scenarios/SwapScenarios.kt | 31 +++++ .../tangem/tests/transfer/AppTransfersTest.kt | 108 ++++++------------ 4 files changed, 94 insertions(+), 89 deletions(-) diff --git a/.claude/skills/write-ui-test/SKILL.md b/.claude/skills/write-ui-test/SKILL.md index f35d144d56..7508749a1f 100644 --- a/.claude/skills/write-ui-test/SKILL.md +++ b/.claude/skills/write-ui-test/SKILL.md @@ -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 diff --git a/.claude/skills/write-ui-test/reference/compose-traps.md b/.claude/skills/write-ui-test/reference/compose-traps.md index 817f5c019a..96e51a4384 100644 --- a/.claude/skills/write-ui-test/reference/compose-traps.md +++ b/.claude/skills/write-ui-test/reference/compose-traps.md @@ -170,16 +170,17 @@ expanding the sheet over the content, so the next click lands on the wrong eleme 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`). **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 -stories are disabled via their WireMock scenario, then opened with `storiesExist = false`.) +`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. -**Polling the animated node does NOT rescue it** — two false fixes: -- `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree (no `useUnmergedTree` option); a - `clickable` node inside a `mergeDescendants` container exists only in the *unmerged* tree → never matches. -- `waitUntil { runCatching { node.assertIsDisplayed() }.isSuccess }` reads the unmerged tree but - `assertIsDisplayed` itself blocks on idle, which never comes → the outer wait times out too. +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 diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt index 9430532499..db4e1a2771 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/SwapScenarios.kt @@ -435,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 +} + diff --git a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt index 4e87d66fd5..d7897ef413 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/transfer/AppTransfersTest.kt @@ -27,35 +27,6 @@ class AppTransfersTest : BaseTestCase() { 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 @@ -84,9 +55,9 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -154,12 +125,12 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert network fee is displayed") { waitForFeeDisplayed() } step("Click on 'Swap tokens' (reverse) button") { onSwapTokenScreen { replaceTokensButton.performClick() } } - assertTransferReady() + step("Assert Transfer mode is ready") { assertTransferReady() } } } @@ -197,11 +168,11 @@ class AppTransfersTest : BaseTestCase() { step("Click on 'Max' amount button") { onSwapTokenScreen { maxAmountButton.performClick() } } - waitForFeeDisplayed() - assertTransferReady() + step("Assert network fee is displayed") { waitForFeeDisplayed() } + step("Assert Transfer mode is ready") { assertTransferReady() } step("Assert 'Transfer' button is enabled") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { transferButton.assertIsEnabled() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { transferButton.assertIsEnabled() } } } } @@ -235,11 +206,11 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(aboveBalanceAmount) + 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") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } } } } @@ -273,28 +244,27 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - waitForFeeDisplayed() - assertTransferReady() + 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") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapTokenScreen { selectFeeIcon.performClick() } } - runCatching { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } }.isSuccess + 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") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { - onSwapTokenScreen { feeAmount.assertIsDisplayed() } - }.isSuccess && onSwapTokenScreenFeeDiffers(marketFee) + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { feeAmount.assertIsDisplayed() } + check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$marketFee'" } } } } @@ -336,9 +306,9 @@ class AppTransfersTest : BaseTestCase() { step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -373,9 +343,9 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) - assertTransferReady() - waitForFeeDisplayed() + step("Enter amount '$amount'") { inputAmount(amount) } + step("Assert Transfer mode is ready") { assertTransferReady() } + step("Assert network fee is displayed") { waitForFeeDisplayed() } } } @@ -425,14 +395,12 @@ class AppTransfersTest : BaseTestCase() { } step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) } - inputAmount(amount) + step("Enter amount '$amount'") { 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 + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapTokenScreen { + insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed() + } } } } @@ -477,8 +445,8 @@ class AppTransfersTest : BaseTestCase() { onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) } } step("Assert '$polygonReceiveName' is displayed") { - composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { - runCatching { onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } }.isSuccess + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } } } step("Assert '$ethereumToken' is not displayed") { @@ -486,10 +454,4 @@ class AppTransfersTest : BaseTestCase() { } } } - - private fun BaseTestCase.onSwapTokenScreenFeeDiffers(previousFee: String): Boolean { - var current = "" - onSwapTokenScreen { current = feeAmount.extractText() } - return current.isNotEmpty() && current != previousFee - } } \ No newline at end of file