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