From 0ce9be5b08bcad15e707c2292ca2ab9951590848 Mon Sep 17 00:00:00 2001 From: Tangem Date: Wed, 24 Jun 2026 23:39:09 +0200 Subject: [PATCH] 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