Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-16 14:57:28 +04:00
commit 39b4d1add9
2179 changed files with 93617 additions and 13563 deletions

View file

@ -0,0 +1,172 @@
---
description: Design-system generations (DS1/DS2/DS3), component pattern, KDoc & API conventions, storybook
paths:
- "core/ui/src/main/java/com/tangem/core/ui/ds2/**"
- "core/ui/src/main/java/com/tangem/core/ui/ds/**"
- "core/ui/src/main/java/com/tangem/core/ui/components/**"
- "features/tester/**"
---
# Design System
The app currently hosts **three generations of the design system (DS)** side by side. They differ by
folder, token set (colors / typography / dimensions), and the `@Preview` wrapper. Knowing which
generation a component belongs to is essential so you don't mix tokens or pull the wrong building blocks.
## Three generations
| Generation | Folder | Colors | Typography | Dimensions | Preview wrapper |
|---|---|---|---|---|---|
| **DS1** (legacy) | `core/ui/src/main/java/com/tangem/core/ui/components/` | `TangemTheme.colors` | `TangemTheme.typography` | `TangemTheme.dimens` | `TangemThemePreview` |
| **DS2** (redesign) | `core/ui/src/main/java/com/tangem/core/ui/ds/` | `TangemTheme.colors2` | `TangemTheme.typography2` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
| **DS3** (target) | `core/ui/src/main/java/com/tangem/core/ui/ds2/` | `TangemTheme.colors3` | `TangemTheme.typography3` | `TangemTheme.dimens2` | `TangemThemePreviewRedesign` |
> Mind the numbering mismatch: **folder `ds` is DS2**, **folder `ds2` is DS3**.
> The `colors2` / `typography2` tokens are `@Deprecated` (ReplaceWith `colors3` / `typography3`).
- **DS1** — the entire current app is built on it. Do **not** add new components here.
- **DS2** — redesign components. A transitional generation; don't write new components in it, only
maintain what already exists.
- **DS3** — the newest design system; **the whole app is being migrated to it**. Build new DS
components here.
## Using DS3 in features
**All DS3 components (folder `ds2`) may be used in features starting from app version 6.0.** Before
6.0 they must not be used on product screens.
If a needed component does not yet exist in DS3, **add it by analogy with the existing ones** (see the
pattern below).
## DS3 component pattern
Study the existing components as references:
- Simple: `ds2/checkbox/TangemCheckmark.kt` — single file, a public `@Composable` function + `@Preview`.
- Composite: `ds2/button/``TangemButton.kt` (public API), `TangemButtonInternal.kt` (private inner
layout), `TangemButtonExt.kt` (variant / size tokens).
Pattern rules:
1. **Package & location.** `com.tangem.core.ui.ds2.<component>`, folder
`core/ui/.../ds2/<component>/`. The component name is `Tangem<Name>`.
2. **DS3 tokens only.** Colors — `TangemTheme.colors3.*`, text — `TangemTheme.typography3.*`,
dimensions — `TangemTheme.dimens2.*`. No `colors` / `colors2` / hardcoded values (literal dp/colors
are acceptable only inside `@Preview`, where you add `@Suppress("MagicNumber")`).
3. **Signature.** `modifier: Modifier = Modifier` is mandatory (defaulting to `Modifier`, placed first
among the optional params or right after the required ones). Express variants/sizes via a nested
`enum` in `object Tangem<Name>` (like `TangemButton.Variant` / `TangemButton.Size`), not boolean flags.
4. **Accessibility.** Pass `contentDescription`, set the `Role`, mark `disabled()` in `semantics`, and
handle focus/press state via `interactionSource`.
5. **KDoc + Figma link.** Above the public function — KDoc describing behavior, every parameter, and a
link to the Figma node (see the KDoc requirements below).
6. **Previews.** Two `@Preview`s (Light + Dark via `UI_MODE_NIGHT_YES`), wrapped in
`TangemThemePreviewRedesign { ... }`, with `TangemTheme.colors3.bg.primary` as the background.
Preview helpers (`PreviewRow`, `Section`, etc.) are private in the same file.
7. **Composite components** (many variants / heavy layout) are split into 3 files like the button:
public `Tangem<Name>.kt`, private `Tangem<Name>Internal.kt`, tokens `Tangem<Name>Ext.kt`.
## API conventions
### Public properties live in the `object`
Any public type the component exposes — variant/size/role/align enums, status classes, constants —
is declared inside the namesake `object Tangem<Name>`, **not** as a top-level type. This keeps a single
`Tangem<Name>.Variant` / `Tangem<Name>.Size` / `Tangem<Name>.Role` namespace at the call site and
avoids polluting the package.
```kotlin
object TangemTopNavigation {
/** Horizontal alignment of the center content slot. */
enum class ContentAlign { Start, Center }
}
// usage: TangemTopNavigation.ContentAlign.Center
```
References: `TangemTopNavigation.ContentAlign`, `TangemNavigationText.Role`, `TangemButton.Variant` /
`TangemButton.Size`.
### Provide convenient overloads
A component should ship ergonomic overloads so callers don't assemble boilerplate for the common case.
Two acceptable shapes:
1. **Additional `@Composable fun` overloads** with simpler parameters that delegate to the base one.
`TangemTopNavigation` has a low-level slot-based overload (`startButton`/`endButton`/`contentColumn`
lambdas) plus several high-level overloads taking `title` / `subtitle` / `onBack` / `onClose` that
wire the predefined buttons and the title/subtitle center for you.
2. **Extension functions on the `object`** for named presets — e.g. `@Composable fun TangemButton.Back(…)`
and `TangemButton.Close(…)` in `TangemButtonExt.kt` expose ready-made button presets while reading
as `TangemButton.Back { … }` at the call site.
Each overload keeps the same rules as the base component (`modifier` first among optionals, DS3 tokens,
its own KDoc — see below).
### Sub-components are first-class
Internal building blocks that are themselves public (e.g. `TangemNavigationText`, used for the
`TangemTopNavigation` title/subtitle slots) follow the **exact same rules** as a top-level component:
DS3 tokens only, `modifier: Modifier = Modifier`, public properties in their own `object`
(`TangemNavigationText.Role`), full KDoc, and their own Storybook entry where it makes sense. Don't
treat "helper" composables as second-class — if a feature can call it, it is a documented DS component.
## KDoc requirements for components
Every public DS component (and any non-trivial public composable) must carry a KDoc block. Use
`ds2/button/TangemButton.kt` and `ds2/checkbox/TangemCheckmark.kt` as the canonical examples.
A component KDoc must contain, in order:
1. **Summary line.** One sentence stating what the component is and which generation it belongs to —
start with `Design-system v2 …` for DS3 components (matches the existing wording).
2. **Figma link.** A markdown link to the exact Figma node:
`[Figma](https://www.figma.com/design/…?node-id=…)`. A component without a Figma reference is not
review-ready.
3. **Behavior notes** (when behavior is non-obvious). A short prose paragraph or a bulleted
`Behavior notes:` list covering state-dependent rendering — loading, disabled/enabled, icon-only
vs. labeled, focus ring, animations, what overrides what. Describe *observable behavior*, not the
implementation.
4. **`@param` for every parameter.** No parameter may be left undocumented — including `modifier`
when its effect is non-trivial (e.g. "Pass `Modifier.fillMaxWidth()` to switch to fixed-width
layout"). Each `@param` states the meaning **and** the consequences of notable values
(`null` → non-interactive, `false` → dimmed & clicks ignored, etc.).
5. **Accessibility guidance** where relevant — e.g. when `contentDescription` should be supplied
(icon-only buttons, loading state, disabled state) and what it announces.
Additional rules:
- Document the **nested `enum`s** (`Variant`, `Size`, `Status`, …) too: a short KDoc on the enum and,
where the options aren't self-explanatory, a one-line description per entry (see `TangemButton.Variant`).
- Keep KDoc about **contract and behavior**, not internals. Implementation comments explaining *why*
a specific approach was taken belong to inline `//` comments inside the body, not the KDoc.
- Reference other DS types with `[TangemSurface]` / `[TangemButton.Variant]` link syntax so they
resolve in the IDE.
- Detekt enforces missing-KDoc-on-public-API style checks on `core:ui`; run `./gradlew :core:ui:detektMain`.
## Storybook
Add every DS3 component to the **Storybook** (module `features/tester`) — a live on-device/emulator
component gallery (Tester → Storybook → DS Components).
Use the **`add-storybook-component`** skill — it wires the entity, the Build factory, the Composable
page, and registers it in the correct list. Run: `/add-storybook-component TangemCheckmark (DS)`.
Page layout guidelines live in
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md`.
## Checklist: adding a new DS3 component
- [ ] Component created under `core/ui/.../ds2/<component>/`, package `com.tangem.core.ui.ds2.<component>`.
- [ ] Named `Tangem<Name>`; first optional parameter is `modifier: Modifier = Modifier`.
- [ ] Uses **only** DS3 tokens: `colors3`, `typography3`, `dimens2`. No hardcoded values outside previews.
- [ ] Variants/sizes expressed as an `enum` inside `object Tangem<Name>` (not a set of boolean flags).
- [ ] All public types (enums, statuses, constants) declared inside the `object Tangem<Name>`.
- [ ] Convenient overloads provided (simpler `@Composable` overloads and/or `object` extension presets).
- [ ] Public sub-components (e.g. `TangemNavigationText`) follow the same rules + KDoc as a full component.
- [ ] States handled: enabled/disabled, press/focus (`interactionSource`), loading (if applicable).
- [ ] Accessibility: `contentDescription`, `Role`, `disabled()` in `semantics`.
- [ ] KDoc per the requirements above (summary + Figma link + behavior notes + every `@param` + a11y).
- [ ] Two `@Preview`s (Light/Dark) in `TangemThemePreviewRedesign`, background `colors3.bg.primary`.
- [ ] Heavy component split into `Tangem<Name>.kt` / `…Internal.kt` / `…Ext.kt`.
- [ ] Storybook page added (`add-storybook-component` skill).
- [ ] Detekt passes: `./gradlew :core:ui:detektMain` (plus
`./gradlew :features:tester:impl:assembleGoogleDebug` if you touched the Storybook).
- [ ] Use in product features only from app version **6.0** onward.

View file

@ -0,0 +1,214 @@
# Unit Testing Rules
This document covers **unit tests** only — sources under `src/test`, running on the JVM via JUnit 5 (Jupiter). UI / instrumentation tests (`src/androidTest`, Kaspresso + Espresso on the JUnit 4 on-device runner) are a separate concern and out of scope here.
## Stack
| Purpose | Library | Version source |
|---|---|---|
| Test runner | JUnit 5 (Jupiter) | `deps.test.junit5` |
| Mocking | MockK | `deps.test.mockk` |
| Flow testing | Turbine | `deps.test.turbine` |
| Assertions | Google Truth | `deps.test.truth` |
| Coroutines | `kotlinx-coroutines-test` | `deps.test.coroutine` |
All unit tests run on JUnit 5. JUnit 4 (`deps.test.junit` = `junit:junit`) is **not** used in `src/test` at all — it survives only in `src/androidTest` instrumentation. Don't add new JUnit 4 unit tests.
Versions live in `gradle/dependencies.toml`. Do not hardcode library coordinates in module build scripts — always go through the catalog.
## Shared test modules
Depend on these via `testImplementation(projects.*)` — never copy their utilities inline.
Build test fixtures with **factory functions that default every argument** (`createXxx(id = 1, name = "Cat", … )`) rather than calling bloated constructors at each call site. A test then overrides only the fields relevant to it, so the intent stays visible and adding a model field doesn't churn every test. This is the idiom behind the `Mock*Factory` classes below — extend them instead of hand-rolling fixtures.
### `:test:core` (pure JVM)
`test/core/src/main/java/com/tangem/test/core/`. Re-exports as `api`: `test.coroutine`, `test.junit5`, `test.mockk`, `test.truth`, `test.turbine`. Use it as the one-line entry point to pull the whole unit-testing stack into a JVM module. Depends on `domain:core` and `arrow.core` (so its utilities can reference domain abstractions like `FlowProducer`).
Utilities:
- `TestCoroutineExt.getEmittedValues(flow)` — collect a `Flow` into a `List` from a `TestScope`.
- `TestFlowProducerTools(scope, dispatcher)` — test double for `FlowProducerTools` that mirrors production `DefaultFlowProducerTools` (retryWhen + fallback + `distinctUntilChanged` + `shareIn`) on a caller-provided test scope/dispatcher, without analytics/logging. Pass `TestScope.backgroundScope` + a dispatcher built from `testScheduler` so the 2s retry delay is virtual-time-controllable. Use it for `FlowProducer` tests instead of mocking `FlowProducerTools`.
- `@ProvideTestModels` — meta-annotation over JUnit 5 `@MethodSource("provideTestModels")` for parameterized tests.
- `TruthArrowExt``assertEither`, `assertEitherRight`, `assertEitherLeft`, `assertSome`, `assertNone` for Arrow types.
### `:common:test` (Android library — legacy, being retired)
`common/test/src/main/java/com/tangem/common/test/`. Factories and fakes for domain/data models. Being phased out in favour of `:test:core` (JVM mechanisms) and `:test:mock` (mock factories); don't add new utilities here.
- `TestAppCoroutineScope(testScope)` — test implementation of `AppCoroutineScope`.
- `MockStateDataStore` — in-memory `DataStore` for tests.
- `Mock*Factory` classes for `CryptoCurrency`, `UserWallet`, `NetworkStatus`, `ScanResponse`, `YieldDTO`, `QuoteResponse`, `UpdateWalletManagerResult` etc.
### `:test:mock`
`test/mock/`. Mock data for models not yet covered elsewhere (currently `MockAccounts`). Add to this module rather than creating new ad-hoc mock files.
## Dispatchers
Never use `Dispatchers.Main`/`IO`/`Default` directly in production code — always inject `CoroutineDispatcherProvider` from `core/utils`.
In tests, override with `TestingCoroutineDispatcherProvider` (defined in `core/utils/src/main/java/com/tangem/utils/coroutines/CoroutineDispatcherProvider.kt`). By default `main`/`mainImmediate`/`io`/`default` are `Dispatchers.Unconfined`, while `single` is a single-thread `Executors.newFixedThreadPool(1)` dispatcher.
For `Model`-layer tests inside features, construct it with a single `StandardTestDispatcher(testScheduler)` for all five roles (built from the enclosing `TestScope`) so `advanceUntilIdle()` controls execution. See any `features/*/impl` model test for the `TestScope.createTestingCoroutineDispatcherProvider()` helper.
## Naming & placement
- **Test class**: `FooTest` (singular noun). Not `FooSpec`, not `FooBehavior`, not `FooTests`.
- **Test method**: backtick-quoted sentence that **must** follow `GIVEN … WHEN … THEN …` (uppercase). The name states the behaviour under test — precondition, action, expected outcome — not the implementation.
```kotlin
@Test
fun `GIVEN currency status emitted WHEN model created THEN analytics sent`() = runTest { … }
```
A part may collapse when trivial (e.g. `GIVEN no wallets WHEN load THEN returns empty`), but all three keywords stay present.
- **Test body**: if the body is more than a one-liner (i.e. has distinct setup / action / check phases), it **must** be marked with `// Arrange`, `// Act`, `// Assert` comments. GWT names the behaviour from the outside; AAA structures the code inside.
- **Location**: mirrored packages under `src/test/kotlin/`. No `src/testFixtures/` — shared helpers go to the modules above.
## Unit-test skeleton (JUnit 5)
```kotlin
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class FooTest {
private val barUseCase: BarUseCase = mockk()
private val dispatchers = TestingCoroutineDispatcherProvider()
private val foo = Foo(barUseCase, dispatchers)
@BeforeEach
fun resetMocks() {
clearMocks(barUseCase)
}
@Test
fun `GIVEN bar returns right WHEN invoke THEN emits value`() = runTest {
// Arrange
coEvery { barUseCase(any()) } returns Either.Right(expected)
// Act
val actual = foo.invoke(input)
// Assert
assertThat(actual).isEqualTo(expected)
coVerify(exactly = 1) { barUseCase(input) }
}
}
```
- `@TestInstance(Lifecycle.PER_CLASS)` is **opt-in per class, not the project default.** Add it only where you need a non-static `@MethodSource`/`provideTestModels` provider or want to share expensive setup across methods (~half of test classes do). The JUnit default stays `PER_METHOD` (a fresh instance per test). Beware: `PER_CLASS` reuses one instance across all methods, so mutable fields leak between tests — reset them in `@BeforeEach`.
- **Group by method under test.** When a class/file exposes several functions and each accumulates many tests, don't keep one flat list — give each function its own `@Nested @TestInstance(Lifecycle.PER_CLASS) inner class`. The nesting maps the test structure onto the production API and keeps per-function setup local to its group.
```kotlin
internal class DesignControllerTest {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class GetDesigns {
@Test fun `GIVEN … WHEN getDesigns THEN all fields included`() { … }
@Test fun `GIVEN limit WHEN getDesigns THEN list is capped`() { … }
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
inner class DeleteDesign {
@Test fun `GIVEN existing id WHEN deleteDesign THEN removed from db`() { … }
}
}
```
- You do **not** declare `useJUnitPlatform()` per module — the `configuration` convention plugin applies it (and the JUnit 5 engine) to every module. See "Gradle wiring" below.
## MockK conventions
- Field-level init: `private val x: T = mockk()`; use `mockk(relaxed = true)` only when stubs are not the subject of the test.
- Stub coroutines with `coEvery { … } returns …` / `returnsMany(...)`; verify with `coVerify { … }`, `coVerify(exactly = n) { … }`, `coVerifyOrder { … }`.
- Create mocks once as `val` fields and reset them with `clearMocks(...)` in `@BeforeEach` — recreating mocks (`x = mockk()` inside `@BeforeEach`) every test is measurably expensive (MockK instantiation dominates the runtime of small tests). Only recreate a field when the subject-under-test itself holds mutable state that must be fresh per test.
- For companion/top-level objects use `mockkObject(Obj)` and pair with `unmockkObject(Obj)` in teardown.
## Flow testing
- Default to `TestScope.getEmittedValues(flow)` (from `:test:core`) when you just want the list of values produced during the test scope — this is the most common approach in the codebase.
- Use **Turbine** (`flow.test { … }`) when you specifically need to assert on the emission *sequence* (ordering, intermediate items, completion/error timing), or for hot `SharedFlow`s where you must control collection start/stop.
- Drive hot sources via `MutableSharedFlow` / `MutableStateFlow` and `advanceUntilIdle()` between emission and assertion.
- For `FlowProducer` tests (retry/fallback/shareIn semantics), inject `TestFlowProducerTools` from `:test:core` and use Turbine + `advanceTimeBy(2001); runCurrent()` to step over the 2s retry window deterministically.
## Parameterized tests
When the same behaviour is exercised over a set of inputs, write **one parameterized test** — not several near-identical methods, and not one method with a stack of `assertThat(...)` calls over different inputs. Repeated asserts hide *which* input failed and stop at the first failure; a parameterized test reports each case separately. Add a new case = add a row to the provider.
Use the project's `@ProvideTestModels` annotation — it wires `@MethodSource("provideTestModels")` for you.
```kotlin
@ParameterizedTest
@ProvideTestModels
fun create(model: CreateModel) = runTest { … }
private data class CreateModel(val input: Input, val expected: Either<Error, Value>)
private fun provideTestModels() = listOf(
CreateModel(input = …, expected = Either.Right(…)),
CreateModel(input = …, expected = Either.Left(Error.Foo)),
)
```
`provideTestModels` is a non-static instance method, so the class needs `@TestInstance(Lifecycle.PER_CLASS)` (or a `@JvmStatic` provider in a companion).
## Assertions
- Default to Truth: `assertThat(actual).isEqualTo(expected)`, `.isInstanceOf(T::class.java)`, `.hasMessageThat().isEqualTo(…)`, `.isNull()`.
- **Assert whole objects, not field-by-field.** When the type is a `data class`, build the expected instance and compare with one `isEqualTo(expected)` — the structural `equals`/`toString` gives a self-explanatory diff. For collections use `.containsExactly(…)` (add `.inOrder()` when order matters). Prefer this over a series of `assertThat(actual.id)…`, `assertThat(actual.name)…` checks, which produce opaque failures and miss unexpected fields.
- For Arrow `Either`/`Option`, prefer `assertEither`, `assertEitherLeft`, `assertEitherRight`, `assertSome`, `assertNone` from `:test:core`.
- Exception testing: `runCatching { … }.exceptionOrNull()` + Truth, not `assertThrows`.
## Feature model tests
`features/*/impl` Decompose models share a heavy dependency graph — extract a `XxxModelTestBase` with pre-built mocks/fixtures and inherit per-scenario test classes from it (see `features/staking/impl/.../presentation/model/StakingModelTestBase` as reference).
Lifecycle:
```kotlin
val model = createModel(testScope = this)
advanceUntilIdle()
// assertions…
model.onDestroy()
```
## Running tests
```bash
./gradlew unitTest # all JVM + debug/googleDebug unit tests (root aggregator)
./gradlew :<module>:testDebugUnitTest # single Android library module
./gradlew :app:testGoogleDebugUnitTest # app module
./gradlew :<jvm-module>:test # pure JVM module
./gradlew :<module>:testDebugUnitTest --tests "com.tangem.<Fqn>Test" # single class
```
The `unitTest` aggregator lives in the root `build.gradle.kts`; it is wired automatically for every `com.android.application`, `com.android.library`, and pure `org.jetbrains.kotlin.jvm` subproject — no need to touch it when adding a new module.
## Gradle wiring for a new test-bearing module
The `configuration` convention plugin (`configureUnitTests` in `plugins/configuration/.../TestConfigurations.kt`) centralizes the JUnit 5 setup for **every** module:
1. `useJUnitPlatform()` on all `Test` tasks — so Jupiter tests are discovered (without it the default JUnit 4 runner runs zero Jupiter tests).
2. `testRuntimeOnly(<test-junit5-engine>)` — the Jupiter runtime engine. The platform without the engine silently runs **zero** tests, so these two are paired in one place.
3. Test logging (full exception format, standard streams, PASSED/SKIPPED/FAILED events, per-task summary).
So a test module must **not** re-declare `useJUnitPlatform()`, the engine, or `testLogging { … }`. It only needs the Jupiter **API** (provided transitively by `:test:core`, or declared explicitly):
```kotlin
// Any module (JVM or Android library) — plugin already supplies platform + engine + logging
plugins {
alias(deps.plugins.kotlin.jvm) // or the android-library convention
id("configuration")
}
dependencies {
testImplementation(projects.test.core) // junit5 (api) + mockk + turbine + truth + coroutine-test
testImplementation(projects.common.test) // add only if the tests need legacy model factories / fakes
}
```
If a module doesn't want the full `:test:core` bundle, declare the Jupiter API directly with `testImplementation(deps.test.junit5)` — the engine still comes from the plugin, so never add `testRuntimeOnly(deps.test.junit5.engine)` per module.
## Module type vs. layer
The domain layer is **not** uniformly pure-JVM: domain modules are split roughly evenly between `org.jetbrains.kotlin.jvm` (pure JVM) and `com.android.library` modules. Don't assume the layer dictates the module type — check the `plugins { }` block to pick the right test task:
- `kotlin.jvm` (pure JVM) → `./gradlew :<module>:test`
- `com.android.library` / `com.android.application``./gradlew :<module>:testDebugUnitTest` (`:app``testGoogleDebugUnitTest`)
`./gradlew unitTest` runs the right task for every module regardless of type.

View file

@ -0,0 +1,150 @@
---
name: add-storybook-component
description: Add a component showcase page to the Tangem storybook (in features/tester). Wires the entity, Build factory, Composable page, and registers it either in the "DS Components" sub-list (first/default target — for design-system components under core.ui.ds2.*) or in the root storybook list (second target — for any other component). Use when asked to "add a storybook page/story", "add <Component> to the storybook", "сделай сторибук для <компонент>", "добавь стори/историю в storybook", or to showcase a DS component in the tester.
allowed-tools: Read, Grep, Glob, Bash, Edit, Write
argument-hint: [component to add, e.g. "TangemCheckbox (DS)" or "MyLegacyCard"]
---
Add a new component page to the Tangem storybook. The storybook lives in
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/`
and renders interactive DS/component showcases on a device or emulator.
This is an **interactive** skill: read the real production component first to get its actual
parameters, enums, and package — never guess the API. Then mirror the closest existing story.
## Two placement targets — pick one
| Target | Use for | List screen | Page dir | Entity supertype |
|---|---|---|---|---|
| **1. DS Components (default)** | Design-system components under `com.tangem.core.ui.ds2.*` (the newest "DS3"/redesign components: `TangemButton`, `TangemBadge`, `TangemRow`, `TangemLoader`, …) | `page/ds/DsComponentsListScreen.kt``buildDsStories()` | `page/ds/<component>/` | `DsStoryBookPage` |
| **2. Other components** | Anything else (legacy/cross-cutting components, backgrounds, effects, typography demos) | `ui/StoryBookListScreen.kt``buildStories()` | `page/<component>/` | `StoryBookPage` |
**Default to Target 1 (DS Components)** when the component lives under `core.ui.ds2.*` or the user
mentions "DS"/"ds3"/"design system". Only the **list screen** and **page directory** differ between
the two targets — everything else (entity declaration file, `StoryBookScreen.kt` routing, factory
pattern) is identical.
> The ONLY behavioral difference of `DsStoryBookPage` vs `StoryBookPage`: `StoryBookViewModel.onBackClick`
> routes a `DsStoryBookPage` back to the DS sub-list, while a plain `StoryBookPage` routes back to the
> root list. That's it.
## Reference
`features/tester/impl/src/main/java/com/tangem/feature/tester/presentation/storybook/STORYBOOK.md` is the canonical doc — read it for the **design guidelines** (mandatory
page layout: single live preview pinned at top + one control per parameter below, chip-selector pattern,
colors, realistic text). This skill covers the *wiring*; STORYBOOK.md covers the *look*.
Best reference implementations to mirror:
- **Stateful DS page with many controls:** `page/ds/button/` (TangemButton — variant/size/background
selectors, toggles, text-scale slider, blur backdrop). Read all three files: `Build.kt`,
`TangemButtonStory.kt`, and the `TangemButtonStory` entity in `entity/StoryBookPage.kt`.
- **Simple stateful page:** `page/ds/loader/` (TangemLoader — single size selector).
- **Stateless page (no params):** a `data object` sibling such as `ButtonsStory`.
## Workflow
1. **Read the production component.** Grep `core/ui/src/main/java/com/tangem/core/ui/ds2/<name>/`
(or wherever it lives) for the composable signature, its `enum`s (Variant/Size/Status/…), and
required vs optional params. The set of parameters becomes the set of controls.
2. **Decide stateless vs stateful:**
- **Stateless** (`data object`) — ONLY if the component has no configurable parameters at all.
- **Stateful** (`data class`) — the normal case: one field per parameter the user can change, each
paired with an `onXxxChange`/`onXxxToggle` lambda.
3. **Pick the target** (see table above) and **mirror the closest sibling**.
4. **Do the 4 edits + 1 new dir** (Steps AE below).
5. **Verify it compiles** (see Build).
## The edits
Assume component `Foo` rendered by `com.tangem.core.ui.ds2.foo.TangemFoo` with a `Variant` enum and an
`isEnabled` flag. Adjust names to the real component. `<page-dir>` =
`page/ds/foo/` for Target 1, or `page/foo/` for Target 2.
### A. Declare the entity in `entity/StoryBookPage.kt`
Stateful (normal):
```kotlin
internal data class TangemFooStory(
val variant: TangemFoo.Variant,
val isEnabled: Boolean,
val onVariantChange: (TangemFoo.Variant) -> Unit,
val onEnabledToggle: () -> Unit,
) : DsStoryBookPage // <- StoryBookPage for Target 2
```
Stateless: `internal data object TangemFooStory : DsStoryBookPage` (or `StoryBookPage`).
Add the matching import for the production type at the top of the file.
### B. Create `<page-dir>/Build.kt`
Stateful — uses `storyPageFactory` + `StateUpdater`:
```kotlin
internal fun StateUpdater<TangemFooStory>.build(): TangemFooStory {
return TangemFooStory(
variant = TangemFoo.Variant.Primary,
isEnabled = true,
onVariantChange = { v -> updateStory { it.copy(variant = v) } },
onEnabledToggle = { updateStory { it.copy(isEnabled = !it.isEnabled) } },
)
}
internal val tangemFooStoryFactory
get() = storyPageFactory(StateUpdater<TangemFooStory>::build)
```
Stateless: `internal val tangemFooStoryFactory: StoryPageFactory = StoryPageFactory { TangemFooStory }`
### C. Create `<page-dir>/TangemFooStory.kt`
`@Composable internal fun TangemFooStory(state: TangemFooStory, modifier: Modifier = Modifier)`
(drop `state` for stateless). Follow STORYBOOK.md design guidelines: live preview pinned at the top
in a `Column`, controls scrolling below. Reuse the chip-selector / toggle-row patterns from
`page/ds/button/TangemButtonStory.kt` (its `Section`, `ChipGrid`, `Chip`, `ToggleRow` are private —
copy the ones you need into the new file). Use representative text, not "Btn".
### D. Register routing in `ui/StoryBookScreen.kt`
Add both imports (entity + page composable share the simple name — Kotlin resolves them by position):
```kotlin
import com.tangem.feature.tester.presentation.storybook.entity.TangemFooStory
import com.tangem.feature.tester.presentation.storybook.page.ds.foo.TangemFooStory
```
Add a branch to the `when (storyState)`:
```kotlin
is TangemFooStory -> TangemFooStory(state = storyState) // stateless: TangemFooStory -> TangemFooStory()
```
### E. Register in the list screen (target-specific)
- **Target 1 (DS):** in `page/ds/DsComponentsListScreen.kt` add the factory import and a row to
`buildDsStories()`:
```kotlin
DsStoryItem(title = "🔘 TangemFoo", factory = tangemFooStoryFactory),
```
- **Target 2 (other):** in `ui/StoryBookListScreen.kt` add the factory import and a row to
`buildStories()`:
```kotlin
StoryItem(title = "🔘 Foo", factory = tangemFooStoryFactory),
```
**Every title must start with an emoji** matching the component category (🔘 buttons, 🏷️ badge,
📋 row, ⏳ loader, 🔤 typography, 🔍 search, 🧭 navigation, 💀 placeholder, ✨ effects, 🪙 token…).
## Build
```bash
./gradlew :features:tester:impl:assembleGoogleDebug
```
Detekt runs via the convention plugin; keep `@file:Suppress("MagicNumber")` on showcase files that use
literal dp/colors (the button story does this). Then run the app, open Tester → Storybook → (DS
Components →) your entry, and confirm the preview + every control works.
## Checklist
- [ ] Read the real component; every meaningful parameter has a control.
- [ ] Entity in `StoryBookPage.kt` extends the correct supertype (`DsStoryBookPage` for DS, else `StoryBookPage`).
- [ ] `Build.kt` factory name is `<camelCaseName>StoryFactory`.
- [ ] Page composable shares the entity's simple name; both imported in `StoryBookScreen.kt`.
- [ ] `when` branch added in `StoryBookScreen.kt` (`is` prefix for stateful, bare for stateless).
- [ ] Registered in the correct list screen with an emoji-prefixed title.
- [ ] Live preview pinned at top, controls below (STORYBOOK.md layout rule).
- [ ] `:features:tester:impl:assembleGoogleDebug` passes.

View file

@ -2,7 +2,7 @@
name: analyze-logs name: analyze-logs
description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation. description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation.
allowed-tools: Read, Grep allowed-tools: Read, Grep
argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit]
--- ---
Analyze the Tangem app user log file at path: `$ARGUMENTS` Analyze the Tangem app user log file at path: `$ARGUMENTS`
@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev
- `MainActivity.*onNewIntent` — deep link or push notification - `MainActivity.*onNewIntent` — deep link or push notification
- `CardSDK_Session.*start card session` — NFC session starts - `CardSDK_Session.*start card session` — NFC session starts
**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):**
Skip this entire group if `--no-secrets-audit` is in arguments.
- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}`
- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}`
- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+`
- Authorization header: `(?i)authorization:\s*\S+`
- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"`
- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"`
- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}`
- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}`
- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b`
- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken
**Error filtering:** When processing error results, skip these noisy matches: **Error filtering:** When processing error results, skip these noisy matches:
- `java.io.IOException: Canceled` — normal request cancellation - `java.io.IOException: Canceled` — normal request cancellation
- `HttpException(code=304` — HTTP "Not Modified" - `HttpException(code=304` — HTTP "Not Modified"
- Bare stacktrace lines starting with `\tat` - Bare stacktrace lines starting with `\tat`
- `<-- HTTP FAILED: java.io.IOException: Canceled` - `<-- HTTP FAILED: java.io.IOException: Canceled`
### Step 6.5: Masking Consistency Check
Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep).
1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked
2. For each unique `host + path-prefix-before-mask`, derive the prefix string
3. For each prefix, Grep the prefix followed by a non-`*` character (`<prefix>[^*\s]`, head_limit: 20)
- If hits found → masking inconsistency: same endpoint has both masked and unmasked variants
- Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number
### Step 7: Deep Dive ### Step 7: Deep Dive
For each significant error found above: For each significant error found above:
@ -207,6 +232,37 @@ Structure your report EXACTLY as follows:
|------|-------|---------| |------|-------|---------|
(chronological: app starts, card sessions, navigation, errors, notable API calls) (chronological: app starts, card sessions, navigation, errors, notable API calls)
## Secrets & PII Audit
Omit this section entirely if `--no-secrets-audit` was passed.
### Health Check
- Total masked tokens (`******`) in log: **N**
- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken"
### Confirmed Leaks (CRITICAL / HIGH)
| Line | Severity | Type | Matched (first 16 chars + `…`) | Context |
|------|----------|------|--------------------------------|---------|
### Masking Inconsistencies
| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line |
|-----------------|-------------|---------------|---------------------|
### Suspected Leaks (MEDIUM / LOW)
| Line | Severity | Type | Pattern Matched | Why Suspect |
|------|----------|------|-----------------|-------------|
**Severity legend:**
- **CRITICAL** — private key / mnemonic / xprv in clear text
- **HIGH** — API key / bearer / JWT / card_public_key visible
- **MEDIUM** — push token, card_id, persistent identifiers
- **LOW** — heuristic patterns that may be false positives (tx hash, content hash)
**Output rules:**
- Never include the full matched value — always truncate to 16 chars + `…`
- For LOW severity, add a "Why Suspect" column explaining typical false positives
- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto`
## Analysis Summary ## Analysis Summary
(2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations. (2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations.
If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.) If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.)

View file

@ -0,0 +1,160 @@
---
name: write-ui-test
description: Write a Kaspresso/Compose instrumentation UI test for the Tangem Android app following project conventions — test class shape, page-object locations, Allure step naming, WireMock scenario setup, synchronization, and meaningful assertions. Covers known Compose traps (PullToRefreshBox swipe, TangemHoldToConfirmButton, Decompose lifecycle, hot-wallet access code) and the build/run/debug flow. Use when the user asks to write, add, port, or fix an instrumentation / androidTest / UI test, a page object, or a test scenario ("напиши UI-тест", "добавь инструментальный тест", "напиши тест в androidTest", "page object", "автотест на экран").
allowed-tools: Read, Grep, Glob, Bash, Edit, Write, Agent
argument-hint: [screen/flow or TC# to cover, e.g. "TangemPay freeze card"]
---
Write an instrumentation (androidTest) UI test for the Tangem Android app. These conventions are
enforced by reviewers (tnagmetulla, dpodoynikov) — applying them up front skips a review round.
This is an **interactive** skill: if scope is ambiguous (which screen, which flow, what the final
assertion should verify), ask before writing. Do not invent UI text or test tags — read the real
production source and reuse existing patterns.
## When to use
Use for instrumented UI tests under `app/src/androidTest/` (Kaspresso + Kakao-Compose), page objects,
and test scenarios. **Not** for JVM/Robolectric unit tests (`testDebugUnitTest`) — those follow a
different setup.
## Workflow
1. **Clarify scope.** Which screen/flow, which Allure TC#, and what the *final assertion* verifies.
Ask if any of these is unclear.
2. **Find an existing sibling test to mirror.** Grep `app/src/androidTest/` for a test on a similar
screen (e.g. `SendViaSwapTest`). Match its structure rather than inventing one. Read the real
production composable to get the actual `testTag`s and string resources — never guess UI text.
3. **Locate / extend page objects** in `com/tangem/screens/` (see Locations). Add new ones there,
never inside the scenario or test file.
4. **Set up WireMock scenarios** in the *test body* if the flow depends on backend state
(see `reference/running-and-debugging.md`).
5. **Write the test** per Conventions below.
6. **Build BOTH APKs, install, run, and classify the result** correctly — Allure post-run hook
failures are not test failures (see `reference/running-and-debugging.md`).
## Porting a test from iOS
When the user asks to **port** an iOS test to Android:
- **Default to the sibling iOS repo `../tangem-app-ios/`** (next to `tangem-app-android`). If that path
doesn't exist, **ask the user** where the iOS repo is — don't guess.
- iOS UI tests live under `TangemUITests/`; look there for the source test, its page objects
(`*Screen`), and accessibility identifiers (`*AccessibilityIdentifiers`).
- Port the *intent and steps*, not the API. Map the iOS stack to the Android one:
XCUITest/accessibility identifiers → Compose `testTag`; iOS `*Screen` page objects → Kotlin page
objects in `com/tangem/screens/`; XCTest assertions → Kaspresso/Truth assertions. Re-derive the real
Android `testTag`s and string resources from production source — never reuse iOS identifier strings.
- **Card/wallet mock mapping — where iOS uses `wallet2`, Android uses the default `Wallet`**
(`openMainScreen()` with no `productType``ProductType.Wallet`). Do NOT port iOS `.wallet2` to
`ProductType.Wallet2`. Other cards map directly: iOS `.twin``ProductType.Twins`, `.xrpNote`
`ProductType.Note`, `.four12``Firmware412MockContent` (via the `mockContent` param). `ProductType.Wallet2`
exists but is a distinct Wallet-2.0-card case, not the iOS-`wallet2` analog.
- **A wallet has no balance until you sync.** The default `Wallet` mock starts with missing derivations
("Some addresses are missing"); the fiat balance shows `—` until you call `synchronizeAddresses()` after
`openMainScreen()` (mirror `TotalBalanceUpdateTest`). Any test asserting a balance/fiat-equivalent must
sync first, then `waitUntil` the value loads — balances re-load asynchronously (e.g. after an app-currency
change the equivalent repaints with a delay).
- The WireMock scenarios are usually shared across platforms, but the branch may differ
(see `reference/running-and-debugging.md`).
## Conventions (must-follow)
### Test class shape
- **Test method names are camelCase and always end with `Test`** (e.g. `groupTokensTest()`,
`renameWalletTest()`). Never use `snake_case` and never the unit-test `GIVEN … WHEN … THEN …`
backtick phrasing — that GWT convention is for JVM unit tests only, not instrumentation tests.
- **Scenario state setup goes in the test body**, not inside the open-the-feature helper. Each test
starts with explicit `step("Set WireMock scenario '$name' to '$state'") { setWireMockScenarioState(name, state) }`
calls, then calls a thin helper (e.g. `openTangemPay()`) that only opens the screen. Mirror the
`SendViaSwapTest` pattern.
- **Open-the-feature helpers stay thin** — no scenarios-as-parameters, no scenario juggling inside.
- **Every scenario name + state is a `val`** at the top of the test method. Reviewers reject magic
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
scenario extension functions; only `flakySafely` is restricted to the `TestCase` body. Caveat: don't
wrap a *mutating* scenario (e.g. one that long-clicks to sign+send) in `flakySafely` — a retry would
re-fire the action; rely on the assertion's own built-in retry instead.
- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` / `is not displayed`.
Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` /
`is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it).
- **No conditional `if (foo.isDisplayedSafely()) foo.performClick()`** for elements that are
deterministically present after `pm clear` — the `if` is dead code. Use a straight `performClick()`.
### Locations
| What | Where |
|------|-------|
| Page objects | `app/src/androidTest/kotlin/com/tangem/screens/…`**always** |
| Common test helpers | `app/src/androidTest/kotlin/com/tangem/common/utils/` |
| Feature scenarios | `app/src/androidTest/kotlin/com/tangem/scenarios/` |
| Cross-feature helper (e.g. `confirmSwapByHolding`) | the **feature-of-origin** scenarios file (e.g. `SwapScenarios.kt`), not the consumer's |
Scenario files orchestrate flows; they must not define page objects or duplicate generic helpers.
### Strings
- **No hardcoded UI text** in matchers. Use `getResourceString(R.string.foo)` from
`com.tangem.core.res.R` or `com.tangem.core.ui.R`. The Detekt rule `UnsafeStringResourceUsage`
enforces this for production code; reviewers extend it to test code informally.
### Allure IDs
- **Every test method gets its own unique `@AllureId`.** Never reuse the same id across two test methods —
not even for two variants of one manual case. If a manual case is split into multiple automated tests
(e.g. a positive and a negative variant), each test must be linked to its own distinct Allure case/id.
(Note: iOS sometimes shares one id across methods — do NOT mirror that here.)
### Assertions
- **Never** use Kotlin's built-in `assert(...)` — Android instrumentation runs don't enable JVM
assertions, so `assert(false)` is a silent no-op. Use Truth / JUnit / Kaspresso / Kakao assertions.
- **Clipboard checks**: `assertClipboardTextEquals(expected, context)` from `common/utils/ClipboardUtils.kt`.
Read displayed text via `KNode.extractText()` first if you need to compare against UI state.
- **Every test ends with a meaningful assertion**, not just an action. A test whose last step is
"Click Submit" without verifying the result gets rejected.
### Waits and synchronization
- **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
This repo enforces "no comments unless WHY is non-obvious", in test code too. One line max, WHY-only —
encode a hidden constraint, not what the code does. Example that earns its keep:
`// Create+confirm screens share ACCESS_CODE_INPUT — gate on confirm-screen title.`
Delete anything explaining WHAT a step does.
## Reference docs
- **`reference/compose-traps.md`** — read when the screen uses `PullToRefreshBox`,
`TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, a hot-wallet import with
an access code, or a target inside a **LazyColumn/LazyRow that may be below the fold** (use a
`KLazyListNode` matcher that auto-scrolls — never a manual swipe). These have silent failure modes that
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 find app-side root causes when the UI fails
silently (the app log in `files/log.txt`, and the WireMock journal).

View file

@ -0,0 +1,246 @@
# Compose UI test traps
Each of these has a **silent** failure mode: the gesture/action appears to run, the test stays green
(or fails for the wrong reason), but the intended behavior never fired. Diagnose with logcat network
traces or a semantics-tree snapshot, not by visually watching the swipe.
## Material3 `PullToRefreshBox` + UiAutomator swipe = silent no-op
`androidx.compose.material3.pulltorefresh.PullToRefreshBox` reacts to overscroll deltas via Compose's
`NestedScrollConnection` from the inner `LazyColumn`. UiAutomator's `device.swipe(x1,y1,x2,y2,steps)`
dispatches platform `MotionEvent`s; the `LazyColumn` receives them as an ordinary scroll, never
produces overscroll, and `onRefresh` never fires — regardless of `steps=30` (fling) or `steps=1000`
(slow drag). Confirmed by `NetworkLogs`: zero refresh calls after the UiAutomator swipe, vs. one
immediate call via the Compose Test API.
**Use the Compose Test API:**
```kotlin
composeTestRule.onNode(hasTestTag(SOME_TAG_INSIDE_THE_BOX))
.performTouchInput {
swipeDown(startY = 0f, endY = visibleSize.height.toFloat() * 6f, durationMillis = 800)
}
```
The shared `pullToRefresh()` in `common/extensions/UiDeviceExt.kt` is UiAutomator-based and works for
*some* screens (a different refresh container), but **not** for Material3 `PullToRefreshBox`. When
porting a test, verify with a logcat network trace, not visual inspection.
## `TangemHoldToConfirmButton` semantics are minimal
The component exposes ONLY `TestTag`, `IsContainer`, `Shape` in Compose semantics — no `Disabled`,
`Role`, or `OnClick`. `assertIsEnabled()` / `assertHasClickAction()` are useless on it.
`Modifier.holdToConfirmGestures(enabled, ...)` early-returns from `pointerInput` when `enabled=false`,
so the hold gesture is silently swallowed: the button looks fine, the user holds, nothing happens,
`onConfirm` never fires.
**Diagnose "silently disabled" from a test:**
1. Snapshot the Compose semantics tree before the hold.
2. Perform the hold: `performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }`.
3. Snapshot again — byte-identical trees mean `onConfirm` didn't run.
4. Or check WireMock request stats for the downstream API call expected after `onConfirm`.
## Asserting enabled/disabled on a `Modifier.clickable` row
When a settings/list row puts `Modifier.clickable(enabled = isClickable, ...)` on the row **container**
(not the title `Text`), the enabled/disabled state lives on that container; the child Texts only carry
`testTag`/text. So `assertIsEnabled()` / `assertIsNotEnabled()` must target the container, matched by a
descendant text — not the title node itself.
Match the container in BOTH states with **click-action OR disabled-semantics**. Do NOT rely on
`hasClickAction()` alone: depending on the Compose version a `clickable(enabled = false)` row may not
expose an onClick action, so a `hasClickAction()`-only matcher finds no node and `assertIsNotEnabled()`
fails with "No node found".
```kotlin
import androidx.compose.ui.test.hasClickAction as withClickAction
import androidx.compose.ui.test.isNotEnabled as withDisabled
val row: KNode = child {
addSemanticsMatcher(withClickAction() or withDisabled()) // matches enabled AND disabled rows
hasAnyDescendant(withText(getResourceString(R.string.row_title))) // narrows to the specific row
useUnmergedTree = true
}
// enabled card: row.assertIsEnabled() ; disabled card: row.assertIsNotEnabled()
```
## `assertTextContains(x)` defaults to exact-segment match, not substring
`SemanticsNodeInteraction.assertTextContains(value, substring = false, ignoreCase = false)` defaults to
`substring = false` — it asserts that some text **segment of the node equals `value` exactly**. Matching
a symbol or fragment inside a larger string (e.g. `"€"` against a balance `"€108,474.21"`) silently
never matches and times out inside a `waitUntil`. Pass `substring = true`:
```kotlin
totalBalanceText.assertTextContains("€", substring = true)
```
Reference tests that pass the *full* string (`assertTextContains("€108,474.21")`) work with the default,
which is why a copy-pasted matcher can mislead.
## Kakao-Compose `child { }`: use DSL matchers, not raw Compose matcher aliases
Inside a `child { … }` / `ComposeScreen` element builder, call the DSL methods (`hasText(...)`,
`hasTestTag(...)`, `hasAnyDescendant(...)`). A common alias is `import androidx.compose.ui.test.hasText
as withText` — but `withText(x)` as a **bare statement** inside the builder just creates a
`SemanticsMatcher` and discards it, registering nothing → `ViewBuilderException: Please set matchers for
your Element!` at run time. `withText`/raw matchers are only valid as *arguments* to a DSL method
(`hasAnyDescendant(withText(name))`), never as a standalone line.
```kotlin
// WRONG — no matcher registered
fun walletNameValue(name: String) = child { withText(name); useUnmergedTree = true }
// RIGHT
fun walletNameValue(name: String) = child { hasText(name); useUnmergedTree = true }
```
## LazyList item below the fold: plain `child { }` finds it but can't click it
A `child { hasTestTag(ITEM); hasAnyDescendant(withText(name)) }` matcher resolves the semantics node
even when the item is composed **off-screen** (LazyColumn keeps a few items past the viewport). But the
node isn't displayed, so `clickWithAssertion()` (`assertIsDisplayed()` first) fails, or `performClick()`
taps nothing. Symptom: the test passes when the item happens to be near the top and fails for items
lower in the list — and a manual swipe "fixes" it. Do **not** patch with a swipe (flaky, the
`clickableSingle` 500ms debounce can also eat fast programmatic clicks).
**Whenever a target lives in a LazyColumn/LazyRow and might be below the fold, build a `KLazyListNode`
matcher up front** — `childWith` scrolls the list to the item before returning it:
```kotlin
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
private val tokensList = KLazyListNode(
semanticsProvider = semanticsProvider, // primary-ctor param is in scope in initializers
viewBuilderAction = { hasTestTag(SomeScreenTestTags.LAZY_LIST) }, // the LazyColumn's OWN tag
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) },
)
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitle(title: String): LazyListItemNode =
tokensList.childWith<LazyListItemNode> {
hasTestTag(SomeScreenTestTags.LAZY_LIST_ITEM)
hasText(title)
useUnmergedTree = true
}
```
Non-obvious points that bite:
- **`childWith` searches the MERGED tree** (it scopes via the list's `viewBuilderAction`, whose
`useUnmergedTree` defaults to `false`). So match the item by `hasText(title)` — on a `MergeDescendants`
item the child texts aggregate onto the item node. `hasAnyDescendant(withText(...))` does **not** match
there. (`useUnmergedTree = true` on the item matcher is inert for the scroll/filter but harmless; keep
it to mirror existing page objects.)
- **The list needs its OWN `testTag` on the `LazyColumn`.** If production tags only the *items* (e.g.
`MARKETS_TOKENS_LIST_ITEM`) and not the container, add a tag to the `LazyColumn` modifier in the
production composable. Reuse the screen's existing `…TestTags.LAZY_LIST` constant when one fits.
- **Scope to the right list when several coexist.** Multiple LazyColumns with the same *item* tag can be
composed at once (e.g. the Add-Funds `ChooseTokenScreen` list AND the main-screen markets sheet, both
using `MARKETS_TOKENS_LIST_ITEM`). A bare top-level `child { hasTestTag(ITEM); … }` is then ambiguous
and may match the wrong screen. `childWith` (and `tokensList.child { … }`) scope through the container
tag via `onNode(LAZY_LIST)` / `hasAnyAncestor(LAZY_LIST)`, so they pick the intended list. Prefer a
unique container tag over hoping the item text is unique.
- **`childWith` returns a `LazyListItemNode`, not a `KNode`.** `clickWithAssertion()` was a `KNode`
extension; it's been generalized to `fun BaseNode<*>.clickWithAssertion()` (in
`common/extensions/KNode.kt`) so it works on both. Both types extend `BaseNode`, and
`assertIsDisplayed()`/`performClick()` live on `BaseNode`.
- `positionMatcher` is only used by `childAt(index)` / `hasLazyListItemPosition`. For `childWith`
(match-by-content) the items don't need to expose `LazyListItemPositionSemantics` — pass the matcher
anyway since the constructor requires it.
Reference: `AddFundsBottomSheetPageObject.trendingTokenWithTitle` and `MainScreenPageObject` (`lazyList`).
## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet)
When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's
**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead —
expanding the sheet over the content, so the next click lands on the wrong element.
- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a
`ScrollToIndex` action that does NOT engage nested scroll.
- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires
when already expanded, races the press, and back often falls through and quits the app.
## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake
Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an
auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a
slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not
by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator.
For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most
are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's
server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test.
(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with
`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree
(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists
only in the *unmerged* tree — will never match it; poll through the page object instead.
## Decompose model lifecycle vs. data refresh
Models (e.g. `TangemPayDetailsModel`) call data fetches from `init {}`, NOT on `ON_RESUME`. Returning
to a screen via `router::pop` does NOT re-fetch. A test that switches WireMock scenarios between an
action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the
stale in-memory data wins.
## Terminal screen never reaches Compose-idle: self-feeding `StateFlow` loop
A screen whose model writes a fresh state back into the same `StateFlow` it observes will recompose
forever, so **any** Compose/Espresso assertion on it times out with `ComposeNotIdleException`
(`autoAdvance=true`) or `AppNotIdleException` "last message = DispatchedContinuation target=Handler"
(`autoAdvance=false`). The classic shape (hit on the send-v2 `ConfirmSuccess` screen, [REDACTED_TASK_KEY]):
```kotlin
combine(uiState, currentRoute)
.onEach { (state, _) -> callback.onResult(state.copy(navigationUM = NavigationUM.Content(onClick = { … }))) }
// callback writes back into uiState → emits again → onEach again → ∞
```
`NavigationUM.Content` is a `data class` whose fields are **lambdas**, recreated every pass → `equals`
is always false → `StateFlow` never dedups → unthrottled loop. No test-side workaround helps (it's an
app loop): not `flakySafely`, not longer timeouts, not mocking external sources, not UiAutomator
(touching the window mid-async-signing aborts the send). **Fix is app-side** — emit once (guard the
`filter`/`distinctUntilChanged` so the self-induced field is ignored). If you see `ComposeNotIdle` on a
*static-looking* success/result screen, suspect this before blaming background polling.
## Animation-gated content via `delay()` never appears under the test clock
Compose UI tests run inside `runTest`**virtual time**. A `LaunchedEffect { delay(600); visible = true }`
that gates the screen body behind `AnimatedVisibility(visible)` will *never* reveal it once the
composition is otherwise idle: `waitForIdle` sees no pending frame-clock awaiters, so it stops without
advancing the virtual clock to the delay's deadline. The body stays empty (you see only the parent
chrome, e.g. a top-bar close icon), the `testTag` is absent, and `assertIsDisplayed` fails as
"not displayed" — **after** burning the full wall-clock timeout. `flakySafely(LONG)` does NOT help:
it retries in wall-clock time while virtual time stays frozen.
Distinguish from the loop trap above: a `delay`-gate gives a clean `AssertionError: … not displayed`
(idle is reached, node just isn't there); the loop gives a `ComposeNotIdle`/`AppNotIdle` timeout.
Fixes: (a) app-side — drop the pre-`delay`, let the enter transition (`slideIn`/`fadeIn`) play on the
frame clock (which `autoAdvance` *does* pump); or (b) put the asserted `testTag` on a node **outside**
the `AnimatedVisibility` so the container exists from frame 0. A plain coroutine `delay` is not a
frame-clock awaiter, so advancing frames won't fire it — only `advanceTimeBy` (with `autoAdvance=false`)
would, which is fragile. Prefer the app-side fix.
## Hot wallet imports with access code
- `openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")` in `BaseScenarios.kt`
handles both flows via the optional param — DO NOT introduce a parallel `importHotWalletWithAccessCode`.
- Access-code **create** and **confirm** screens share the same `ACCESS_CODE_INPUT` testTag. Gate the
confirm-screen action on the confirm-screen's unique title:
```kotlin
composeTestRule.waitUntilAtLeastOneExists(
hasText(getResourceString(CoreUiR.string.access_code_confirm_title)),
timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG,
)
```
- Tangem Pay eligibility (`PaeraCustomer`) rejects hot wallets with `authType=NoPassword` — those tests
must use the access-code path.

View file

@ -0,0 +1,183 @@
# Building, running, and debugging instrumentation tests
## Both APKs matter
Instrumentation tests need TWO APKs:
- `:app:assembleGoogleMocked``app-google-mocked.apk` — production code under test
- `:app:assembleGoogleMockedAndroidTest``app-google-mocked-androidTest.apk` — the test code
If you change production code and rebuild only the test APK, **the installed main APK stays old** and
your fix doesn't take effect. Symptom: "the fix doesn't help" — except it does, you just ran the
unfixed build.
```bash
# Build both
./gradlew :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest
# Install each
adb install -r -t <path-to-app-google-mocked.apk>
adb install -r -t <path-to-app-google-mocked-androidTest.apk>
```
## Run a single test (manual)
```bash
adb shell pm clear com.tangem.wallet.mocked
curl -X POST http://localhost:8081/__admin/scenarios/reset
adb shell am instrument -w \
-e class "com.tangem.tests.tangempay.TangemPayTest#freezeUnfreezeCard_TogglesCardState" \
com.tangem.wallet.mocked.test/com.tangem.common.HiltTestRunner
```
## Harness: orchestrator vs. raw `am instrument`
The app is configured `execution = "ANDROIDX_TEST_ORCHESTRATOR"` (`app/build.gradle.kts`). The orchestrator
runs **each test method in its own process** (and can clear app data between them). It is still 100%
local — it runs on the same emulator; nothing remote about it.
Raw `adb shell am instrument` runs **all selected tests in one shared process**, which has two failure
modes that look like test bugs but aren't:
- Running several tests in one invocation → `IllegalStateException: There are multiple DataStores active
for the same file` mid-run. Run them one at a time (with `pm clear` between) if you must use raw
`am instrument`.
- Tests that re-scan the card inside **Card/Device Settings** (the "Scan card or ring" gate) →
`IllegalStateException: Tangem SDK is null after re-registering with foreground activity`. The existing
`ResetCardTest` crashes identically under raw `am instrument`. These only pass via the orchestrator.
**Prefer the orchestrator** (it's what CI/Marathon use). Run a class or method through Gradle:
```bash
./gradlew :app:connectedGoogleMockedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest
# or a single method: ...class=com.tangem.tests.DetailsTest#someTest
# or several classes: ...class=com.tangem.tests.DetailsTest,com.tangem.tests.SecurityModeTest
```
Gradle installs both APKs, runs via the orchestrator, then **uninstalls them** — so a following raw
`am instrument` reports `Unable to find instrumentation info`; reinstall both APKs first. Read results
from the JUnit XML (authoritative pass/fail counts), not just stdout:
```bash
ls -t app/build/outputs/androidTest-results/connected/mocked/flavors/google/*.xml | head -1
# inspect tests="…" failures="…" errors="…" skipped="…" and the <testcase>/<failure> nodes
```
## Running against local WireMock
Every instrumentation test runs with `ApiEnvironment.MOCK` (forced in `BaseTestCase.setupHooks`), so the
app's API base URLs point at `wiremock.tests-d.com` — i.e. tests **always** talk to WireMock, never the
real backend. By default that's the **remote** WireMock at `wiremock.tests-d.com`. To use a **local**
WireMock instead, pass `wiremockBaseUrl`: `WireMockRedirectInterceptor` then rewrites every
`wiremock.tests-d.com` request to your local instance.
Emulator addressing matters — `localhost` inside an emulator is the **emulator itself**, not your host:
- Use the host alias **`http://10.0.2.2:8081`** (no extra setup), **or**
- `http://localhost:8081` **with** `adb reverse tcp:8081 tcp:8081` run first.
Pass it through the orchestrator (recommended):
```bash
curl -s -X POST http://localhost:8081/__admin/scenarios/reset # start clean
./gradlew :app:connectedGoogleMockedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest \
-Pandroid.testInstrumentationRunnerArguments.wiremockBaseUrl=http://10.0.2.2:8081
```
(Raw `am instrument` equivalent: `-e wiremockBaseUrl http://10.0.2.2:8081` — subject to the harness
caveats above.)
**If a screen hangs / you get `ComposeNotIdleException` (infinite recomposition):** that usually means a
request the app made wasn't served (endless retry/loading), *not* a test bug. Ask WireMock what it
didn't match — this is the smoking gun:
```bash
curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(.method) \(.url)"'
```
`unmatched: 0` means the URL plumbing is correct and local WireMock served everything — look elsewhere
(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the
local instance is missing.
## When the UI fails silently, the cause is usually app-side — two places to look
A screen failing silently with correct locators (fee shows "—", a banner never appears, a button stays
disabled) is usually missing mock *data* or an app-side gate, not a test bug. Two diagnostics find it:
- **The app's own log is in `files/log.txt`, not logcat** — the mocked build routes `TangemLogger` to a
file, so `adb logcat` shows nothing. Fastest path to a root cause (e.g. it surfaced
`IllegalStateException: No native currency found` → a native coin missing from the mock):
```bash
adb exec-out run-as <pkg> cat files/log.txt | grep -iE "Error|Exception|<feature>"
```
- **The WireMock journal separates "mock missing" from "app never asked"**
`/__admin/requests/unmatched` finds missing mappings, but if `unmatched=0` *and* the expected request
is also absent from the full log (`/__admin/requests`), the app never issued it (a data/state gate) →
fix the mock data or the app, not the mappings.
## "UiAutomationService already registered" — retry, it's not a failure
Back-to-back `am instrument` runs sometimes fail instantly with `UiAutomationService … already
registered!` — a teardown race between runs, not a test failure. Retry. (The orchestrator avoids it by
spacing runs — another reason to confirm a flaky-looking suite via the orchestrator, not raw
`am instrument`.)
## Classify the result — Allure noise vs. real failure
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →
`AllureResultsHack.testRunFinished` throws `NoSuchFileException` → reported as
`Tests run: 1, Failures: 1` with a stack trace starting at `AllureResultsHack`. **This is a post-run
hook failure, NOT a test logic failure.**
Distinguish:
- First stack frame is `AllureResultsHack.testRunFinished` → infra hook noise; ignore it.
- Kaspresso step logs show all `SUCCEED` for steps 1..N → the test passed.
- A REAL failure shows `java.lang.AssertionError` inside the test's own classes
(e.g. `at com.tangem.tests.X.foo$lambda…`). When auto-classifying CLI output, key off the presence
of `java.lang.AssertionError` vs. only `original_screenshots`.
## `@Ignore` on instrumentation tests
- Pattern: `@Ignore("https://tangem.atlassian.net/browse/AND-XXXXX")` above `@Test`.
- When ignored, `am instrument -e class …` reports `OK (0 tests)` with `Tests run: 0`
(NOT `Skipped: 1`). Auto-detection should match the zero-test count.
## WireMock cheatsheet
Without a `wiremockBaseUrl` arg the app hits the **remote** WireMock (`wiremock.tests-d.com`); pass the
arg to redirect to a local instance (see "Running against local WireMock"). Default local port: `8081`.
```bash
# Set a scenario state — PUT, not POST
curl -X PUT http://localhost:8081/__admin/scenarios/<name>/state \
-H "Content-Type: application/json" -d '{"state":"<state>"}'
# Reset all scenarios
curl -X POST http://localhost:8081/__admin/scenarios/reset
# Inspect
curl http://localhost:8081/__admin/mappings | jq
curl http://localhost:8081/__admin/scenarios | jq '.scenarios[] | {name, state}'
```
- Mocks repo: default to the sibling directory `../tangem-api-mocks/` (i.e. next to
`tangem-app-android`). If that path doesn't exist, **ask the user** where the mocks repo is rather
than guessing.
- The repo is **branch-per-suite** — dozens of feature branches (e.g. `send-via-swap-p1`,
`account-creation`, `swap-express-mocks`, `android-tangem-pay-mocks`). There is no universal
default branch; check out the one the suite under test expects. If it's unclear which branch holds
the mappings for your flow, ask the user. Mappings live under `mocks/mappings/`, response bodies
under `mocks/__files/`.
- **State transitions are atomic per `requiredScenarioState`.** If a scenario defines an `AfterDeposit`
mapping for `/customer/balance` but not `/customer/me`, a request to `/customer/me` after switching
to `AfterDeposit` falls through. Check *both* endpoints when an "after" assertion fails.
## Misc
- `./gradlew unitTest` aggregates all debug/googleDebug + JVM-module tests — faster than per-module
tasks for verifying a broad change (but it's for *unit* tests, not instrumentation).
- Detekt config lives in the `tangem-android-tools` git submodule — look there before assuming a local
`.detekt.yml`.
- Path discipline: stay at the repo root; `cd` into the mocks repo only when needed and prefer absolute
paths (the shell session resets cwd).

View file

@ -9,6 +9,11 @@
"type": "stdio", "type": "stdio",
"command": "npx", "command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"] "args": ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
},
"notion": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.notion.com/mcp"]
} }
} }
} }

View file

@ -107,16 +107,13 @@ configurations.all {
configurations.androidTestImplementation { configurations.androidTestImplementation {
exclude(module = "protobuf-lite") exclude(module = "protobuf-lite")
} }
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
dependencies { dependencies {
implementation(projects.domain.legacy) implementation(projects.domain.legacy)
implementation(projects.libs.blockchainSdk) implementation(projects.libs.blockchainSdk)
implementation(projects.domain.account) implementation(projects.domain.account)
implementation(projects.domain.account.status) implementation(projects.domain.account.status)
implementation(projects.domain.addressBook)
implementation(projects.domain.appsflyer)
implementation(projects.domain.models) implementation(projects.domain.models)
implementation(projects.domain.core) implementation(projects.domain.core)
api(projects.domain.common) api(projects.domain.common)
@ -236,6 +233,8 @@ dependencies {
implementation(projects.common.ui) implementation(projects.common.ui)
/** Features */ /** Features */
implementation(projects.features.addressBook.api)
implementation(projects.features.addressBook.impl)
implementation(projects.features.rating.impl) implementation(projects.features.rating.impl)
implementation(projects.features.referral.impl) implementation(projects.features.referral.impl)
implementation(projects.features.referral.domain) implementation(projects.features.referral.domain)
@ -255,8 +254,8 @@ dependencies {
implementation(projects.features.tokendetails.impl) implementation(projects.features.tokendetails.impl)
implementation(projects.features.manageTokens.api) implementation(projects.features.manageTokens.api)
implementation(projects.features.manageTokens.impl) implementation(projects.features.manageTokens.impl)
implementation(projects.features.sendV2.api) implementation(projects.features.send.api)
implementation(projects.features.sendV2.impl) implementation(projects.features.send.impl)
implementation(projects.features.qrScanning.api) implementation(projects.features.qrScanning.api)
implementation(projects.features.qrScanning.impl) implementation(projects.features.qrScanning.impl)
implementation(projects.features.staking.api) implementation(projects.features.staking.api)
@ -283,6 +282,8 @@ dependencies {
implementation(projects.features.onboardingV2.impl) implementation(projects.features.onboardingV2.impl)
implementation(projects.features.stories.api) implementation(projects.features.stories.api)
implementation(projects.features.stories.impl) implementation(projects.features.stories.impl)
implementation(projects.features.survey.api)
implementation(projects.features.survey.impl)
implementation(projects.features.txhistory.api) implementation(projects.features.txhistory.api)
implementation(projects.features.txhistory.impl) implementation(projects.features.txhistory.impl)
implementation(projects.features.biometry.api) implementation(projects.features.biometry.api)
@ -319,6 +320,12 @@ dependencies {
implementation(projects.features.tangempay.main.impl) implementation(projects.features.tangempay.main.impl)
implementation(projects.features.tangempay.onboarding.api) implementation(projects.features.tangempay.onboarding.api)
implementation(projects.features.tangempay.onboarding.impl) implementation(projects.features.tangempay.onboarding.impl)
implementation(projects.features.virtualAccounts.onboarding.impl)
implementation(projects.features.virtualAccounts.onboarding.api)
implementation(projects.features.virtualAccounts.main.impl)
implementation(projects.features.virtualAccounts.main.api)
implementation(projects.features.virtualAccounts.details.impl)
implementation(projects.features.virtualAccounts.details.api)
implementation(projects.features.tokenRecieve.api) implementation(projects.features.tokenRecieve.api)
implementation(projects.features.tokenRecieve.impl) implementation(projects.features.tokenRecieve.impl)
implementation(projects.features.yieldSupply.api) implementation(projects.features.yieldSupply.api)
@ -422,8 +429,6 @@ dependencies {
/** Testing libraries */ /** Testing libraries */
testImplementation(projects.test.core) testImplementation(projects.test.core)
testImplementation(projects.common.test) testImplementation(projects.common.test)
testImplementation(deps.test.junit)
testRuntimeOnly(deps.test.junit5.engine)
androidTestImplementation(deps.test.junit.android) androidTestImplementation(deps.test.junit.android)
androidTestImplementation(deps.test.espresso) androidTestImplementation(deps.test.espresso)
androidTestImplementation(deps.test.espresso.intents) androidTestImplementation(deps.test.espresso.intents)

View file

@ -24,6 +24,7 @@ import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.PreferencesKeys import com.tangem.datasource.local.preferences.PreferencesKeys
import com.tangem.datasource.local.walletmanager.WalletManagersStore import com.tangem.datasource.local.walletmanager.WalletManagersStore
import com.tangem.datasource.utils.WireMockRedirectInterceptor import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.domain.account.supplier.SingleAccountListSupplier
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION import com.tangem.features.pushnotifications.api.utils.PUSH_PERMISSION
import com.tangem.tap.MainActivity import com.tangem.tap.MainActivity
@ -63,6 +64,9 @@ abstract class BaseTestCase : TestCase(
@Inject @Inject
lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase lateinit var getSelectedWalletSyncUseCase: GetSelectedWalletSyncUseCase
@Inject
lateinit var singleAccountListSupplier: SingleAccountListSupplier
private val hiltRule = HiltAndroidRule(this) private val hiltRule = HiltAndroidRule(this)
private val apiEnvironmentRule = ApiEnvironmentRule() private val apiEnvironmentRule = ApiEnvironmentRule()
private val permissionRule = GrantPermissionRule.grant( private val permissionRule = GrantPermissionRule.grant(
@ -183,9 +187,42 @@ abstract class BaseTestCase : TestCase(
"GASLESS_APPROVAL_ENABLED" to true, "GASLESS_APPROVAL_ENABLED" to true,
"MAIN_SCREEN_QR_SCANNING_ENABLED" to true, "MAIN_SCREEN_QR_SCANNING_ENABLED" to true,
"ADD_AND_MANAGE_TOKENS_ENABLED" to true, "ADD_AND_MANAGE_TOKENS_ENABLED" to true,
"ASSETS_DISCOVERY_ENABLED" to true,
"VISA_ONBOARDING_ENABLED" to true, "VISA_ONBOARDING_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true, // Version-gated toggles released in versions <= 6.0 — forced on so tests run against the actual
// build even when the app version resolves to 1.0.0-SNAPSHOT on CI (then 1.0.0 < x.xx would
// disable them). On the releases/6.0 branch every toggle with version <= 6.0 ships enabled.
// 5.37
"HEDERA_ERC20_ENABLED" to true,
// 5.39
"STAKING_ETH_ENABLED" to true,
"DYNAMIC_ADDRESSES_ENABLED" to true,
"SOLANA_TX_HISTORY_ENABLED" to true,
"SOLANA_SCALED_UI_AMOUNT_ENABLED" to true,
"SWAP_AB_ENABLED" to true,
"AND_15310_ADD_FUNDS_STAGE1" to true, "AND_15310_ADD_FUNDS_STAGE1" to true,
"AND_15009_SWAP_PROVIDER_FILTER_ENABLED" to true,
"AND_15101_TANGEM_PAY_HOT_WALLET_ONBOARDING" to true,
"AND_15402_ADI_MAIN_SCREEN_DEFAULT_ENABLED" to true,
"AND_15103_SWAP_RATE_EXPERIENCE_ENABLED" to true,
"AND_15122_SWAP_PREDEFINED_BUTTONS_ENABLED" to true,
"TWI_1512_HIDE_STORIES_FOR_REFERRAL_ENABLED" to true,
// 5.39.2
"AND_15154_YIELD_PROMO_ENABLED" to true,
// 5.40
"TWI_1377_MANAGE_FUNDS" to true,
// 6.0
"APP_REDESIGN_ENABLED" to true,
"TWI_1326_YIELD_MODE_SWAP_ENABLED" to true,
"AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED" to true,
"AND_15120_SWAP_INTEGRATED_APPROVE" to true,
"AND_15596_ONBOARDING_PUSH_NOTIFICATION_DOUBLE_ASK_AB_ENABLED" to true,
"AND_15258_QUICK_TOP_UP_ENABLED" to true,
"AND_15368_VISA_PAY_REDESIGN" to true,
"AND_15364_VISA_PAY_CARD_CLOSE" to true,
"AND_15489_EXPRESS_SHARE_BUTTON_ENABLED" to true,
"AND_15235_VISA_MULTIPLE_CARDS" to true,
"AND_15715_SWAP_BEST_DEX_RATE_ENABLED" to true,
) )
) )
} }

View file

@ -48,6 +48,10 @@ object TestConstants {
const val USER_TOKENS_API_SCENARIO = "user_tokens_api" const val USER_TOKENS_API_SCENARIO = "user_tokens_api"
const val REFERRAL_API_SCENARIO = "referral_api" const val REFERRAL_API_SCENARIO = "referral_api"
const val QUOTES_API_SCENARIO = "quotes_api" const val QUOTES_API_SCENARIO = "quotes_api"
const val CREATE_USER_WALLET_API_SCENARIO = "create_user_wallet_api"
const val WALLET_TOKENS_API_SCENARIO = "wallet_tokens_api"
const val MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO = "moralis_evm_token_balances_api"
const val PROVIDERS_API_SCENARIO = "networks_providers"
const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk" const val SEED_PHRASE_12 = "they cram join fantasy unfair observe true theory buffalo bus exchange walk"
const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " + const val SEED_PHRASE_15 = "genuine try deer upset connect sausage diary rule price shallow fit faculty leopard " +
@ -60,6 +64,9 @@ object TestConstants {
"bread much nature basic fun iron benefit egg error prosper" "bread much nature basic fun iron benefit egg error prosper"
const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash" const val SVS_SEED_PHRASE_12 = "diagram thunder merit soup muscle amused refuse usual ring couch popular wash"
const val SEED_PHRASE_HAPPY_PATH =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility" const val TANGEM_PAY_ELIGIBILITY_SCENARIO = "tangem_pay_eligibility"
const val TANGEM_PAY_ACCESS_CODE = "517384" const val TANGEM_PAY_ACCESS_CODE = "517384"
} }

View file

@ -115,5 +115,13 @@ private fun extractText(node: SemanticsNode): String? {
private fun parseVolume(node: SemanticsNode): Double? { private fun parseVolume(node: SemanticsNode): Double? {
val text = extractText(node) ?: return null val text = extractText(node) ?: return null
return text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() val multiplier = when {
text.contains('T', ignoreCase = true) -> 1_000_000_000_000.0
text.contains('B', ignoreCase = true) -> 1_000_000_000.0
text.contains('M', ignoreCase = true) -> 1_000_000.0
text.contains('K', ignoreCase = true) -> 1_000.0
else -> 1.0
}
val number = text.replace("[^0-9.]".toRegex(), "").toDoubleOrNull() ?: return null
return number * multiplier
} }

View file

@ -4,13 +4,19 @@ import android.os.SystemClock
import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.ComposeTimeoutException
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.ComposeTestRule
import io.github.kakaocup.compose.node.core.BaseNode
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
fun KNode.clickWithAssertion() { fun BaseNode<*>.clickWithAssertion() {
assertIsDisplayed() assertIsDisplayed()
performClick() performClick()
} }
fun KNode.clickWhenEnabled() {
assertIsEnabled()
performClick()
}
fun KNode.assertTextContainsSafe( fun KNode.assertTextContainsSafe(
text: String, text: String,
substring: Boolean = false, substring: Boolean = false,

View file

@ -4,8 +4,8 @@ import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.wallet.R import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_SHORT
import io.github.kakaocup.kakao.common.utilities.getResourceString
fun BaseTestCase.swipeVertical( fun BaseTestCase.swipeVertical(
direction: SwipeDirection, direction: SwipeDirection,
@ -31,21 +31,6 @@ fun BaseTestCase.pullToRefresh(steps: Int = 1000) {
) )
} }
fun BaseTestCase.swipeMarketsBlock(direction: SwipeDirection) {
val searchBarText = device.uiDevice
.findObject(By.textContains(getResourceString(R.string.markets_search_header_title)))
val bounds = searchBarText.visibleBounds
val centerX = bounds.centerX()
val startY = bounds.centerY()
val endY = when (direction) {
SwipeDirection.UP -> 50
SwipeDirection.DOWN -> device.uiDevice.displayHeight - 100
}
device.uiDevice.swipe(centerX, startY, centerX, endY, 100)
}
fun BaseTestCase.openTheAppFromRecents() { fun BaseTestCase.openTheAppFromRecents() {
device.uiDevice.waitForIdle() device.uiDevice.waitForIdle()
@ -113,6 +98,12 @@ fun BaseTestCase.restartApp(packageName: String) {
waitForIdle() waitForIdle()
} }
fun BaseTestCase.clickOnSystemButton(buttonName: String) {
device.uiDevice.wait(Until.hasObject(By.text(buttonName)), WAIT_UNTIL_TIMEOUT_SHORT)
device.uiDevice.findObject(By.text(buttonName))?.click()
?: throw AssertionError("System '$buttonName' button not found")
}
enum class SwipeDirection { enum class SwipeDirection {
UP, DOWN UP, DOWN
} }

View file

@ -0,0 +1,20 @@
package com.tangem.common.utils
/**
* Helper for inspecting individual nodes of a BIP-44-style derivation path string
* (e.g. one read from `Network.derivationPath` of a token in the domain account model).
*/
object DerivationPathHelper {
/**
* Returns the [index1Based]-th node of a derivation path, ignoring the leading `m`.
* For "m/44'/0'/1'/0/0": node 1 = "44'", node 3 = "1'", node 5 = "0".
*/
fun nodeAt(derivationPath: String, index1Based: Int): String {
val nodes = derivationPath.removePrefix("m/").split("/")
require(index1Based in 1..nodes.size) {
"Node #$index1Based is out of range for path '$derivationPath' (${nodes.size} nodes)"
}
return nodes[index1Based - 1]
}
}

View file

@ -1,14 +1,25 @@
package com.tangem.scenarios package com.tangem.scenarios
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.account.Account
import com.tangem.screens.accounts.onAccountDetailsScreen import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onAccountInfoEditorScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreenTopBar import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onWalletSettingsScreen import com.tangem.screens.onWalletSettingsScreen
import com.tangem.utils.logging.TangemLogger
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
private const val ACCOUNT_POLL_INTERVAL_MS = 500L
fun BaseTestCase.openWalletSettingsScreen() { fun BaseTestCase.openWalletSettingsScreen() {
step("Open 'Wallet details' screen") { step("Open 'Wallet details' screen") {
@ -19,6 +30,15 @@ fun BaseTestCase.openWalletSettingsScreen() {
} }
} }
fun BaseTestCase.startAccountCreation() {
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Account info editor' screen is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
}
fun BaseTestCase.openAccountDetails(accountName: String) { fun BaseTestCase.openAccountDetails(accountName: String) {
step("Click on account: '$accountName'") { step("Click on account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() } onWalletSettingsScreen { accountItem(accountName).clickWithAssertion() }
@ -28,6 +48,46 @@ fun BaseTestCase.openAccountDetails(accountName: String) {
} }
} }
fun BaseTestCase.checkUnsavedChangesCreationModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_create))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.assertUnsavedChangesEditionModal() {
step("Assert 'Unsaved changes' alert is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert 'Unsaved changes' alert has proper title") {
onDialog { title.assertTextContains(getResourceString(R.string.account_unsaved_dialog_title)) }
}
step("Assert 'Unsaved changes' alert has proper description for account creation") {
onDialog {
text.assertTextContains(getResourceString(R.string.account_unsaved_dialog_message_edit))
}
}
step("Assert 'Keep editing' button is displayed in alert with proper text") {
onDialog { keepEditButton.assertIsDisplayed() }
}
step("Assert 'Discard' button is displayed in alert") {
onDialog { discardButton.assertIsDisplayed() }
}
}
fun BaseTestCase.archiveAccount() { fun BaseTestCase.archiveAccount() {
step("Assert 'Archive' button is displayed") { step("Assert 'Archive' button is displayed") {
onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() } onAccountDetailsScreen { archiveAccountButton.assertIsDisplayed() }
@ -99,4 +159,53 @@ fun BaseTestCase.restoreArchivedAccount(accountName: String) {
.restoreButton.clickWithAssertion() .restoreButton.clickWithAssertion()
} }
} }
} }
/**
* Polls [singleAccountListSupplier] for the selected wallet until a [Account.CryptoPortfolio] with the given
* [derivationIndex] appears with a non-empty token list, then returns it.
*
* Per-account token derivation paths live in the domain account model
* ([Account.CryptoPortfolio.cryptoCurrencies] [com.tangem.domain.models.network.Network.derivationPath]),
* not in the tester-menu "Addresses info" (which reads from the account-agnostic wallet managers store and
* only ever shows main/base derivations). Reading the model directly is the reliable source for asserting
* per-account derivations.
*/
fun BaseTestCase.awaitCryptoPortfolioAccount(derivationIndex: Int): Account.CryptoPortfolio {
val walletId = getSelectedWalletSyncUseCase().getOrNull()?.walletId
?: error("No selected wallet found")
var account: Account.CryptoPortfolio? = null
runBlocking {
withTimeout(WAIT_UNTIL_TIMEOUT_VERY_LONG) {
while (true) {
val candidate = singleAccountListSupplier.getSyncOrNull(walletId)
?.accounts
?.filterIsInstance<Account.CryptoPortfolio>()
?.firstOrNull { it.derivationIndex.value == derivationIndex }
if (candidate != null && candidate.cryptoCurrencies.isNotEmpty()) {
TangemLogger.i(
"Account with derivation index $derivationIndex resolved: " +
"${candidate.cryptoCurrencies.size} token(s)",
)
account = candidate
return@withTimeout
}
delay(ACCOUNT_POLL_INTERVAL_MS)
}
}
}
return requireNotNull(account) {
"Account with derivation index $derivationIndex was not found for wallet $walletId"
}
}
/**
* Returns all derivation paths of tokens whose name equals [tokenName] (case-insensitive) within this account.
*/
fun Account.CryptoPortfolio.derivationPathsForToken(tokenName: String): List<String> = cryptoCurrencies
.filter { it.name.equals(tokenName, ignoreCase = true) }
.mapNotNull { it.network.derivationPath.value }

View file

@ -2,8 +2,6 @@ package com.tangem.scenarios
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.waitUntilAtLeastOneExists
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
@ -142,12 +140,15 @@ fun BaseTestCase.openMainScreenWithExistingHotWallet(seedPhrase: String, accessC
fun BaseTestCase.synchronizeAddresses( fun BaseTestCase.synchronizeAddresses(
balance: String? = null, balance: String? = null,
isBalanceAvailable: Boolean = true isBalanceAvailable: Boolean = true,
assertBalance: Boolean = true,
) { ) {
step("Click on 'Synchronize addresses' button") { step("Click on 'Synchronize addresses' button") {
onMainScreen { synchronizeAddressesButton.clickWithAssertion() } onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
} }
if (!assertBalance) return
when { when {
!isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") { !isBalanceAvailable -> step("Assert wallet balance = '$DASH_SIGN'") {
onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) } onMainScreen { totalBalanceText.assertTextContains(DASH_SIGN) }
@ -175,7 +176,10 @@ fun BaseTestCase.openDeviceSettingsScreen() {
onDetailsScreen { walletNameButton.performClick() } onDetailsScreen { walletNameButton.performClick() }
} }
step("Click on 'Device settings' button") { step("Click on 'Device settings' button") {
onWalletSettingsScreen { deviceSettingsButton.clickWithAssertion() } onWalletSettingsScreen {
scrollToDeviceSettings()
deviceSettingsButton.clickWithAssertion()
}
} }
} }

View file

@ -6,105 +6,41 @@ import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.checkSingleCurrencyMainScreen( fun BaseTestCase.checkSingleCurrencyMainScreen(cardTitle: String) {
cardBlockchain: String,
cardTitle: String,
withTransactions: Boolean = false,
withWalletImage: Boolean = true
) {
step("Assert card title equal '$cardTitle'") { step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) } onMainScreen { walletNameText.assertTextEquals(cardTitle) }
} }
if (withWalletImage) { step("Assert 'Add funds' button is displayed") {
step("Assert card image is displayed") { //TODO: create assertion method for checking images onMainScreen { addFundsButton.assertIsDisplayed() }
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
} }
step("Assert 'Receive' button is displayed") { step("Assert 'Transfer' button is displayed") {
onMainScreen { receiveButton.assertIsDisplayed() } onMainScreen { transferButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onMainScreen { sendButton.assertIsDisplayed() }
}
step("Assert 'Sell' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() }
} }
step("Assert 'Swap' button is not displayed") { step("Assert 'Swap' button is not displayed") {
onMainScreen { swapButton.assertIsNotDisplayed() } onMainScreen { swapButton.assertIsNotDisplayed() }
} }
step("Assert 'Market Price' on single card main screen is displayed") {
onMainScreen { marketPriceBlock().assertIsDisplayed() }
}
step("Assert 'Market Price' title equals $cardBlockchain Market Price") {
onMainScreen { marketPriceText.assertTextContains("$cardBlockchain Market Price") }
}
step("Swipe up") { step("Swipe up") {
swipeVertical(SwipeDirection.UP) swipeVertical(SwipeDirection.UP)
} }
if (withTransactions) {
step("Assert 'Transactions' block is displayed") {
onMainScreen { transactionsExplorerText.assertIsDisplayed() }
}
step("Assert 'Transactions' title is displayed") {
onMainScreen { transactionsTitle.assertIsDisplayed() }
}
step("Assert 'Explorer' icon is displayed") {
onMainScreen { transactionsExplorerIcon.assertIsDisplayed() }
}
} else {
step("Assert empty 'Transactions' block is displayed") {
onMainScreen { emptyTransactionBlock.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block icon is displayed") {
onMainScreen { emptyTransactionBlockIcon.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block text is displayed") {
onMainScreen { emptyTransactionBlockText.assertIsDisplayed() }
}
step("Assert empty 'Transactions' block 'Explore' button is displayed") {
onMainScreen { emptyTransactionBlockExploreButton.assertIsDisplayed() }
}
}
step("Assert 'Add & Manage' button is not displayed") { step("Assert 'Add & Manage' button is not displayed") {
onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() } onMainScreen { addAndManageButtonWithoutLazySearch.assertIsNotDisplayed() }
} }
} }
fun BaseTestCase.checkMultiCurrencyMainScreen( fun BaseTestCase.checkMultiCurrencyMainScreen(
devicesCount: String,
cardTitle: String, cardTitle: String,
withWalletImage: Boolean = true
) { ) {
step("Assert card title equal '$cardTitle'") { step("Assert card title equal '$cardTitle'") {
onMainScreen { walletNameText.assertTextEquals(cardTitle) } onMainScreen { walletNameText.assertTextEquals(cardTitle) }
} }
if (withWalletImage) {
step("Assert card image is displayed") {
onMainScreen { walletImage.assertIsDisplayed() }
}
} else {
step("Assert card image is not displayed") {
onMainScreen { walletImage.assertIsNotDisplayed() }
}
}
step("Assert devices count equal to '$devicesCount'") {
onMainScreen { walletDevicesCount.assertTextContains(devicesCount) }
}
step("Assert 'Add funds' button is displayed") { step("Assert 'Add funds' button is displayed") {
onMainScreen { addFundsButton.assertIsDisplayed() } onMainScreen { addFundsButton.assertIsDisplayed() }
} }
step("Assert 'Swap' button is displayed") { step("Assert 'Swap' button is displayed") {
onMainScreen { swapButton.assertIsDisplayed() } onMainScreen { swapButton.assertIsDisplayed() }
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() } onMainScreen { transferButton.assertIsDisplayed() }
} }
step("Assert 'Send' button is not displayed") { step("Assert 'Send' button is not displayed") {
onMainScreen { sendButton.assertIsNotDisplayed() } onMainScreen { sendButton.assertIsNotDisplayed() }
@ -125,8 +61,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
step("Assert 'Swap' button is enabled") { step("Assert 'Swap' button is enabled") {
onMainScreen { swapButton.assertIsEnabled() } onMainScreen { swapButton.assertIsEnabled() }
} }
step("Assert 'Sell' button is enabled") { step("Assert 'Transfer' button is enabled") {
onMainScreen { sellButton.assertIsEnabled() } onMainScreen { transferButton.assertIsEnabled() }
} }
} else { } else {
step("Assert 'Add funds' button is not enabled") { step("Assert 'Add funds' button is not enabled") {
@ -135,8 +71,8 @@ fun BaseTestCase.assertActionButtonsForMultiCurrencyWallet(isEnabled: Boolean =
step("Assert 'Swap' button is not enabled") { step("Assert 'Swap' button is not enabled") {
onMainScreen { swapButton.assertIsNotEnabled() } onMainScreen { swapButton.assertIsNotEnabled() }
} }
step("Assert 'Sell' button is not enabled") { step("Assert 'Transfer' button is not enabled") {
onMainScreen { sellButton.assertIsNotEnabled() } onMainScreen { transferButton.assertIsNotEnabled() }
} }
} }
} }

View file

@ -5,6 +5,12 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.screens.onDeviceSettingsScreen import com.tangem.screens.onDeviceSettingsScreen
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.scanCardInDeviceSettings() {
step("Click on 'Scan card or ring' button") {
onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() }
}
}
fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) { fun BaseTestCase.openResetCardScreen(withBackup: Boolean = false) {
step("Click on 'Scan card or ring' button") { step("Click on 'Scan card or ring' button") {
onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() } onDeviceSettingsScreen { scanCardOrRingButton.clickWithAssertion() }

View file

@ -0,0 +1,139 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
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.extensions.clickWithAssertion
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.screens.*
import io.qameta.allure.kotlin.Allure.step
/**
* From the recipient step: fill the address and advance to the 'Send confirm' screen.
* Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in extensions on [BaseTestCase].
*/
fun BaseTestCase.enterRecipientAndOpenSendConfirm(recipientAddress: String) {
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) }
}
step("Click on 'Next' button until 'Send confirm' screen opens") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
}
}
/** Enter the send amount, then fill the recipient and open the 'Send confirm' screen. */
fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type '$amount' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amount)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
enterRecipientAndOpenSendConfirm(recipientAddress)
}
/**
* On the 'Send confirm' screen, open the network-fee selector and switch the fee token from the
* native coin to the given (stablecoin) token the core gasless action repeated across the suite.
*/
fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) {
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Click on '$coinName' fee token to open 'Choose token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(coinName).performClick() } }.isSuccess
}
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
/**
* Open an existing hot wallet (gasless signing needs a hot wallet, not the mock card), set the
* portfolio and quotes mocks, and reach the send amount input for the given token.
*/
fun BaseTestCase.openGaslessSendScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
userTokensState: String,
quotesState: String,
) {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
/**
* Open an existing hot wallet, select the token to send and choose the swap target token/network
* the shared entry into the gasless send-via-swap flow. Scenario states stay in the test body.
*/
fun BaseTestCase.openSendViaSwapScreenWithHotWallet(
seedPhrase: String,
tokenName: String,
swapTokenName: String,
networkName: String,
networkType: String? = null,
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Select '$swapTokenName' as the token to receive via swap") {
selectTokenToSendViaSwap(
swapTokenName = swapTokenName,
networkName = networkName,
networkType = networkType,
)
}
}
/**
* Send-via-swap amount entry: type the amount, advance past the quote-gated 'Next' button (waiting
* until it becomes enabled once the swap quote loads), then fill the recipient and open the
* 'Send confirm' screen. Uses `composeTestRule.waitUntil` because `flakySafely` is unavailable in
* extensions on [BaseTestCase].
*/
fun BaseTestCase.enterSwapAmountAndOpenSendConfirm(amount: String, recipientAddress: String) {
step("Type amount '$amount' in input field") {
onSendScreen { amountInputTextField.performTextReplacement(amount) }
}
step("Click on 'Next' button") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendScreen {
nextButton.assertIsEnabled()
nextButton.performClick()
}
}.isSuccess
}
}
enterRecipientAndOpenSendConfirm(recipientAddress)
}

View file

@ -1,28 +1,28 @@
package com.tangem.scenarios package com.tangem.scenarios
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.screens.onMarketsExchangesScreen import com.tangem.screens.onMarketsExchangesScreen
import com.tangem.screens.onMarketsScreen import com.tangem.screens.onMarketsScreen
import com.tangem.screens.onMarketsTokenDetailsScreen import com.tangem.screens.onMarketsTokenDetailsScreen
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openMarketTokenDetailsScreen(blockchainName: String, tokenName: String) { fun BaseTestCase.openTokenDetailsFromMarketsScreen(blockchainName: String, tokenName: String) {
step("Open 'Markets' screen") { step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() } onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Click on 'Search' placeholder") {
onMarketsScreen { searchThroughMarketPlaceholder.performClick() }
}
step("Click on $blockchainName blockchain") { step("Click on $blockchainName blockchain") {
waitForIdle() waitForIdle()
onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(blockchainName).clickWithAssertion() }
} }
step("Click on $tokenName token") { step("Click on 'In your portfolio' block") {
waitForIdle()
onMarketsTokenDetailsScreen { inYourPortfolioBlock.clickWithAssertion() }
}
step("Click on $tokenName token in 'Your portfolio' bottom sheet") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsTokenDetailsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
} }
@ -54,11 +54,12 @@ fun BaseTestCase.openMarketsScreen() {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' screen") { step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() } onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle() waitForIdle()
} }
} }
@OptIn(ExperimentalTestApi::class)
fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) { fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAllButton: Boolean = false) {
openMarketsScreen() openMarketsScreen()
if (shouldClickSeeAllButton) if (shouldClickSeeAllButton)
@ -69,9 +70,8 @@ fun BaseTestCase.openMarketsExchangesScreen(tokenName: String, shouldClickSeeAll
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Scroll down") { step("Scroll to 'Listed on exchanges' block") {
swipeVertical(SwipeDirection.UP) onMarketsScreen { scrollToListedOnBlock() }
swipeVertical(SwipeDirection.UP)
} }
step("Click on 'Listed on exchanges' block") { step("Click on 'Listed on exchanges' block") {
onMarketsScreen { listedOnBlockContainer.performClick() } onMarketsScreen { listedOnBlockContainer.performClick() }

View file

@ -0,0 +1,98 @@
package com.tangem.scenarios
import com.tangem.common.BaseTestCase
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.clickWithAssertion
import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import com.tangem.tap.domain.sdk.mocks.MockProvider
import io.qameta.allure.kotlin.Allure.step
/** 'Add Wallet' scans a card immediately (no type chooser), so [mockContent] must be set before the click. */
fun BaseTestCase.addNewCardWallet(mockContent: MockContent) {
step("Click 'More' button on TopBar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
MockProvider.setMocks(mockContent)
step("Click on 'Add Wallet' button (scans a new hardware wallet)") {
onDetailsScreen { addWalletButton.clickWithAssertion() }
}
// Gate on the top-bar More button, not the container — the bottom Markets sheet can leave the container un-"displayed".
step("Assert 'Main' screen is displayed with the new wallet") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onMainScreenTopBar { moreButton.assertIsDisplayed() } }.isSuccess
}
}
// The added card is the newest pager page; its "Synchronize addresses" prompt is off-screen until swiped to.
step("Synchronize the new card wallet's addresses") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
var shown = false
onMainScreen { shown = synchronizeAddressesButton.isDisplayedSafely() }
if (!shown) onMainScreen { swipeToAdjacentWallet(toPrevious = false) }
shown
}
// Let the pager fling settle — a click mid-animation is eaten by the button's clickableSingle debounce.
waitForIdle()
onMainScreen { synchronizeAddressesButton.clickWithAssertion() }
// The prompt clears once the card's addresses are derived (re-scan + reload over many, some failing, RPCs).
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
var generated = false
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
generated
}
}
}
fun BaseTestCase.clickDisplayedTokenOnMain(tokenName: String) {
step("Click on token '$tokenName' on the visible wallet") {
onMainScreen { clickDisplayedToken(tokenName) }
}
}
fun BaseTestCase.switchToPreviousWallet() {
step("Swipe wallet card to the previous wallet") {
onMainScreen { swipeToAdjacentWallet(toPrevious = true) }
}
}
/** Picks a [token] the recipient [walletName] already holds, via the wallet tab. */
fun BaseTestCase.selectReceiveTokenOnWallet(token: String, walletName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Select wallet tab '$walletName'") {
onBuyTokenScreen { walletTab(walletName).performClick() }
}
step("Click on token with name '$token'") {
onBuyTokenScreen { tokenWithTitle(token).performClick() }
}
}
/** Adds [token] to [recipientWalletName] which lacks it, via market search. */
fun BaseTestCase.addMissingReceiveTokenToWallet(token: String, recipientWalletName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Type '$token' in search field") {
onSwapSelectTokenScreen {
searchBarBlock.performClick()
searchBarBlock.performTextInput(token)
}
}
step("Click on market token '$token'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapSelectTokenScreen { marketsTokenWithName(token).performClick() } }.isSuccess
}
}
// The 'Add token' sheet pre-selects the recipient (the only wallet missing the token, since the source already holds it).
step("Assert recipient wallet '$recipientWalletName' is selected") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onAddToPortfolioScreen { walletName(recipientWalletName).assertIsDisplayed() } }.isSuccess
}
}
step("Click on 'Add' button") {
onAddToPortfolioScreen { addButton.performClick() }
}
}

View file

@ -6,11 +6,14 @@ import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.kaspersky.kaspresso.testcases.core.testcontext.TestContext
import com.tangem.screens.* import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
fun BaseTestCase.openSendScreen( fun BaseTestCase.openSendScreen(
@ -34,8 +37,11 @@ fun BaseTestCase.openSendScreen(
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
} }
@ -91,11 +97,11 @@ fun BaseTestCase.openSendAddressScreen(
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Assert 'Send' button is not dimmed") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) } onTokenDetailsScreen { transferButton.clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Send' button in bottom sheet") {
onTokenDetailsScreen { sendButton().performClick() } onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {
@ -109,6 +115,13 @@ fun BaseTestCase.openSendAddressScreen(
step("Assert 'Send Address' container is displayed") { step("Assert 'Send Address' container is displayed") {
onSendAddressScreen { container.assertIsDisplayed() } onSendAddressScreen { container.assertIsDisplayed() }
} }
step("Wait for recipient list to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
onSendAddressScreen { addressesShimmer.assertIsNotDisplayed() }
}.isSuccess
}
}
} }
fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) { fun BaseTestCase.checkScanQrScreen(emptyClipboard: Boolean = true) {
@ -193,6 +206,20 @@ fun BaseTestCase.openSendConfirmScreenViaNextButton() {
} }
} }
fun BaseTestCase.openSendConfirmScreenViaContinueButton() {
step("Click on 'Continue' button") {
onSendAddressScreen {
addressesShimmer.assertIsNotDisplayed()
continueButton.assertIsDisplayed()
continueButton.assertIsEnabled()
continueButton.performClick()
}
}
step("Assert 'Send' button on 'Send confirm' screen is displayed") {
onSendConfirmScreen { sendButton.assertIsDisplayed() }
}
}
fun BaseTestCase.openSendSuccessScreenViaLongClickOnSendButton() { fun BaseTestCase.openSendSuccessScreenViaLongClickOnSendButton() {
step("Long click on 'Send' button") { step("Long click on 'Send' button") {
onSendConfirmScreen { onSendConfirmScreen {
@ -239,13 +266,100 @@ fun BaseTestCase.checkSendViaSwapSuccessScreen() {
} }
} }
/** From the token details screen, open the transfer bottom sheet and reach the send amount input. */
fun BaseTestCase.openSendFromTokenDetails() {
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
}
/** Open an existing hot wallet and reach the send amount input for [tokenName]. */
fun BaseTestCase.openSendScreenWithHotWallet(seedPhrase: String, tokenName: String) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
openSendFromTokenDetails()
}
fun BaseTestCase.getNetworkFeeAmount(): String {
var fee = ""
step("Read current network fee amount") {
onSendConfirmScreen { fee = feeAmount.extractText() }
}
return fee
}
fun BaseTestCase.switchFeeToFastAndApply() {
val fastOption = getResourceString(R.string.common_fee_selector_option_fast)
step("Click on fee selector icon") {
onSendConfirmScreen { feeSelectorIcon.performClick() }
}
// Selecting a non-custom speed auto-applies and closes the fee selector — no 'Done' step.
step("Click on '$fastOption' fee option") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(fastOption).performClick() }
}
}
fun BaseTestCase.assertNetworkFeeChanged(previousFee: String) {
step("Assert network fee changed from '$previousFee'") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching {
var current = previousFee
onSendConfirmScreen { current = feeAmount.extractText() }
current != previousFee
}.getOrDefault(false)
}
}
}
/** Reads the network fee amount on the 'Send confirm' screen (empty while it shows a loading shimmer). */
fun BaseTestCase.readNetworkFeeAmount(): String {
var fee = ""
onSendConfirmScreen { fee = feeAmount.extractText() }
return fee
}
/**
* Wait until the network fee value stops changing across two checks the send button stays disabled
* (and the hold-to-confirm gesture is swallowed) until the fee re-fetch settles. The hold button has
* no enabled/disabled semantics, so waiting on the fee value is the only reliable readiness signal.
*/
fun TestContext<Unit>.waitUntilNetworkFeeIsStable(readFee: () -> String) {
step("Wait for the network fee to finish loading") {
var previousFee: String? = null
flakySafely(timeoutMs = WAIT_UNTIL_TIMEOUT_LONG, intervalMs = FEE_STABILITY_INTERVAL_MS) {
val currentFee = readFee()
val isStable = currentFee.isNotEmpty() && currentFee == previousFee
previousFee = currentFee
if (!isStable) throw AssertionError("Network fee is still settling (current='$currentFee')")
}
}
}
private const val FEE_STABILITY_INTERVAL_MS = 750L
fun BaseTestCase.assertNetworkFeeContains(currencySymbol: String) {
step("Assert network fee contains '$currencySymbol'") {
onSendConfirmScreen { feeAmount.assertTextContains(currencySymbol, substring = true) }
}
}
fun BaseTestCase.selectTokenToSendViaSwap( fun BaseTestCase.selectTokenToSendViaSwap(
swapTokenName: String, swapTokenName: String,
networkName: String, networkName: String,
networkType: String? = null, networkType: String? = null,
) { ) {
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Click on 'Swap to another token' button") { step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() } onSendScreen { swapToAnotherTokenButton.performClick() }

View file

@ -9,13 +9,18 @@ import androidx.compose.ui.test.performTouchInput
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS import com.tangem.common.constants.TestConstants.HOLD_DURATION_MS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertVisibility import com.tangem.common.extensions.assertVisibility
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWhenEnabled
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.isDisplayedSafely import com.tangem.common.extensions.isDisplayedSafely
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.HotWalletAccessCodeTestTags import com.tangem.core.ui.test.HotWalletAccessCodeTestTags
import com.tangem.screens.* import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.MockContent
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.Allure.step
import com.tangem.common.ui.R as CommonUiR import com.tangem.common.ui.R as CommonUiR
@ -43,8 +48,8 @@ fun BaseTestCase.openSwapScreen(
} }
SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") { SwapEntryPoint.TokenDetails -> step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.clickWhenEnabled() }
} }
SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") { SwapEntryPoint.MarketsTokenDetails -> step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() }
@ -167,7 +172,7 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
when (feeType) { when (feeType) {
FeeType.Market -> { FeeType.Market -> {
step("Click on 'Market' item") { step("Click on 'Market' item") {
onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.performClick() } onSwapSelectNetworkFeeBottomSheet { marketSelectorItem.clickWithAssertion() }
} }
step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") { step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
@ -175,7 +180,7 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
} }
FeeType.Fast -> { FeeType.Fast -> {
step("Click on 'Fast' item") { step("Click on 'Fast' item") {
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.performClick() } onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
} }
step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") { step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) } onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
@ -184,49 +189,23 @@ fun BaseTestCase.selectFeeType(feeType: FeeType, selectedFeeAmount: String) {
} }
} }
fun BaseTestCase.selectFeeTypeWithGasless(feeType: FeeType, selectedFeeAmount: String) { fun BaseTestCase.selectFeeTypeAndReadFee(feeType: FeeType): String {
step("Click on 'Select fee' icon") { step("Click on 'Select fee' icon") {
onSwapTokenScreen { selectFeeIcon.performClick() } onSwapTokenScreen { selectFeeIcon.performClick() }
} }
step("Click on '$feeType' item") {
when (feeType) {
FeeType.Market -> selectMarketFee(selectedFeeAmount)
FeeType.Fast -> selectFastFee(selectedFeeAmount)
}
}
private fun BaseTestCase.selectMarketFee(selectedFeeAmount: String) {
step("Deselect current fee and select 'Market'") {
onSwapSelectNetworkFeeBottomSheet { onSwapSelectNetworkFeeBottomSheet {
if (fastSelectorItem.isDisplayedSafely()) { when (feeType) {
fastSelectorItem.performClick() FeeType.Market -> marketSelectorItem.clickWithAssertion()
FeeType.Fast -> fastSelectorItem.clickWithAssertion()
} }
marketSelectorItem.performClick()
} }
} }
step("Click on 'Apply' button") { var fee = ""
onSwapSelectNetworkFeeBottomSheet { applyButton.performClick() } step("Read displayed '$feeType' fee amount") {
} onSwapTokenScreen { fee = feeAmount.extractText() }
step("Assert fee amount is equal to 'Market' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
}
}
private fun BaseTestCase.selectFastFee(selectedFeeAmount: String) {
step("Deselect current fee and select 'Fast'") {
onSwapSelectNetworkFeeBottomSheet {
if (marketSelectorItem.isDisplayedSafely()) {
marketSelectorItem.performClick()
}
fastSelectorItem.performClick()
}
}
step("Click on 'Apply' button") {
onSwapSelectNetworkFeeBottomSheet { applyButton.performClick() }
}
step("Assert fee amount is equal to 'Fast' fee:'$selectedFeeAmount'") {
onSwapTokenScreen { feeAmount.assertTextContains(selectedFeeAmount, substring = true) }
} }
return fee
} }
fun BaseTestCase.chackUnableToCoverFeeNotification(networkName: String, currencySymbol: String) { fun BaseTestCase.chackUnableToCoverFeeNotification(networkName: String, currencySymbol: String) {
@ -284,6 +263,112 @@ fun BaseTestCase.checkSwapWarning(
} }
} }
/** Scans a card wallet and opens Swap for [tokenName] in [fromAccountName] without choosing the receive token yet. */
fun BaseTestCase.openSwapForTokenInAccount(
tokenName: String,
fromAccountName: String = "Account 1",
mockContent: MockContent? = null,
) {
step("Open 'Main' screen") {
openMainScreen(mockContent = mockContent)
}
step("Synchronize addresses") {
synchronizeAddresses(assertBalance = false)
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
}
/** 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,
) {
openSwapForTokenInAccount(tokenName, fromAccountName, mockContent)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
/** Like [openSwapInTransferMode] but imports a hot wallet first — required for broadcasting flows (the mock card can't sign). */
fun BaseTestCase.openSwapInTransferModeWithHotWallet(
tokenName: String,
seedPhrase: String,
fromAccountName: String = "Account 1",
toAccountName: String = "Account 2",
) {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
step("Generate missing addresses") {
generateMissingHotWalletAddresses()
}
step("Wait for addresses to be generated") {
waitForAddressesGenerated()
}
navigateToSwapForToken(tokenName, fromAccountName)
step("Choose identical receive token '$tokenName' from '$toAccountName'") {
chooseIdenticalReceiveToken(tokenName = tokenName, receiveAccountName = toAccountName)
}
}
private fun BaseTestCase.navigateToSwapForToken(tokenName: String, fromAccountName: String) {
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)
}
}
// Hot wallets derive locally, so the second account's missing addresses are generated without a card scan when prompted.
fun BaseTestCase.generateMissingHotWalletAddresses() {
var notificationShown = false
onMainScreen { notificationShown = synchronizeAddressesButton.isDisplayedSafely() }
if (notificationShown) {
onMainScreen { synchronizeAddressesButton.performClick() }
}
}
// The receive selector shows "No address" until the second account's derivation lands; the prompt disappears when it does.
fun BaseTestCase.waitForAddressesGenerated() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
var generated = false
onMainScreen { generated = !synchronizeAddressesButton.isDisplayedSafely() }
generated
}
}
/** Picks the identical [tokenName] in [receiveAccountName]; the receive list collapses the other account, so its header is expanded first. */
fun BaseTestCase.chooseIdenticalReceiveToken(tokenName: String, receiveAccountName: String) {
step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() }
}
step("Expand account '$receiveAccountName' in receive selector") {
onSwapSelectTokenScreen { tokenWithName(receiveAccountName).performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
fun BaseTestCase.chooseReceiveToken(tokenName: String) { fun BaseTestCase.chooseReceiveToken(tokenName: String) {
step("Click on 'Choose token' button") { step("Click on 'Choose token' button") {
onSwapTokenScreen { chooseTokenButton.performClick() } onSwapTokenScreen { chooseTokenButton.performClick() }
@ -293,6 +378,95 @@ fun BaseTestCase.chooseReceiveToken(tokenName: String) {
} }
} }
/** Reopens the receive selector via the receive-card icon and picks [tokenName] directly — the reopened selector keeps the account expanded. */
fun BaseTestCase.changeReceiveToken(tokenName: String) {
step("Open receive token selector") {
onSwapTokenScreen { receiveSelectTokenIcon.performClick() }
}
step("Click on token with name '$tokenName'") {
onSwapSelectTokenScreen { tokenWithName(tokenName).performClick() }
}
}
/**
* From a clean start: open the main screen (cold by default, or an existing hot wallet when
* [seedPhrase] is given), open Swap for [fromTokenName], choose [receiveTokenName] to receive and
* enter [amount]. Scenario states stay in the test body.
*/
fun BaseTestCase.openSwapAmountScreen(
fromTokenName: String,
receiveTokenName: String,
amount: String,
seedPhrase: String? = null,
) {
if (seedPhrase == null) {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
} else {
step("Open 'Main' screen with existing hot wallet") {
openMainScreenWithExistingHotWallet(seedPhrase)
}
}
step("Click on token with name: '$fromTokenName'") {
onMainScreen { tokenWithTitleAndAddress(fromTokenName).clickWithAssertion() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Choose receive token '$receiveTokenName'") {
chooseReceiveToken(receiveTokenName)
}
step("Input swap amount '$amount'") {
waitForIdle()
onSwapTokenScreen {
textInput.clickWithAssertion()
textInput.performTextReplacement(amount)
}
}
step("Wait for the receive amount to load") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSwapTokenScreen { receiveAmount.assertIsDisplayed() } }.isSuccess
}
}
}
/**
* Opens the swap 'Network fee' bottom sheet, retrying the click until the fee selector shows.
* Single action without its own step wrap the call in a `step(...)`.
*/
fun BaseTestCase.openSwapNetworkFeeSelector() {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_VERY_LONG) {
runCatching { onSwapTokenScreen { networkFeeBlock.performClick() } }
runCatching { onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() } }.isSuccess
}
}
/**
* Opens the fee selector and switches the fee-paying token from [currentFeeToken] to [newFeeToken],
* then applies. Works both ways coin -> stablecoin and back.
*/
fun BaseTestCase.switchFeeTokenAndApply(currentFeeToken: String, newFeeToken: String) {
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Click on '$currentFeeToken' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(currentFeeToken).performClick() }
}
step("Select '$newFeeToken' as the fee-paying token") {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { onSendFeeSelectorBottomSheet { feeTokenItem(newFeeToken).performClick() } }.isSuccess
}
}
step("Click on 'Apply' button") {
waitForIdle()
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
}
/** Holds the last BASE_BUTTON; enters [accessCode] if a hot wallet prompts for it. */ /** Holds the last BASE_BUTTON; enters [accessCode] if a hot wallet prompts for it. */
fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) { fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
val buttonMatcher = hasTestTag(BaseButtonTestTags.BUTTON) val buttonMatcher = hasTestTag(BaseButtonTestTags.BUTTON)
@ -316,6 +490,15 @@ fun BaseTestCase.confirmSwapByHolding(accessCode: String? = null) {
} }
} }
// Caller asserts the outcome — transfer mode has no in-progress marker to wait on.
fun BaseTestCase.holdToConfirmTransfer() {
composeTestRule.onNode(
hasTestTag(BaseButtonTestTags.BUTTON) and
hasText(getResourceString(CoreUiR.string.swapping_transfer_action)),
).performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }
waitForIdle()
}
sealed class SwapEntryPoint { sealed class SwapEntryPoint {
object MainScreen : SwapEntryPoint() object MainScreen : SwapEntryPoint()
object TokenDetails : SwapEntryPoint() object TokenDetails : SwapEntryPoint()
@ -328,4 +511,35 @@ enum class FeeType {
Fast 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
}

View file

@ -0,0 +1,80 @@
package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.MarketsTestTags
import com.tangem.core.ui.test.TokenActionsTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText
class AddFundsBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddFundsBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val buyButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val receiveButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
val buyTokenButton: KNode = child {
hasTestTag(TokenActionsTestTags.BUY_ACTION)
useUnmergedTree = true
}
fun userTokenWithTitle(tokenTitle: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasAnyDescendant(withTestTag(TokenElementsTestTags.TOKEN_TITLE))
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}
private val trendingTokensList = KLazyListNode(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BuyTokenScreenTestTags.LAZY_LIST) },
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position ->
SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position)
},
)
@OptIn(ExperimentalTestApi::class)
fun trendingTokenWithTitle(tokenTitle: String): LazyListItemNode =
trendingTokensList.childWith<LazyListItemNode> {
hasTestTag(MarketsTestTags.TOKENS_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAddFundsBottomSheet(function: AddFundsBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,27 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasClickAction
import com.tangem.common.BaseTestCase
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddToPortfolioPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddToPortfolioPageObject>(semanticsProvider = semanticsProvider) {
fun walletName(walletName: String): KNode = child {
hasText(walletName)
useUnmergedTree = true
}
val addButton: KNode = child {
hasText(getResourceString(R.string.common_add))
hasClickAction()
}
}
internal fun BaseTestCase.onAddToPortfolioScreen(function: AddToPortfolioPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -11,18 +11,32 @@ import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class AddTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AddTokenBottomSheetPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<AddTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val title: KNode = child { val title: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE) hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_add_token)) hasText(getResourceString(R.string.common_add_token))
} }
val closeButton: KNode = child {
hasTestTag(BaseBottomSheetTestTags.CLOSE_BUTTON)
useUnmergedTree = true
}
val addButton: KNode = child { val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add)) hasText(getResourceString(R.string.common_add))
useUnmergedTree = true useUnmergedTree = true
} }
val laterButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_later))
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) = internal fun BaseTestCase.onAddTokenBottomSheet(function: AddTokenBottomSheetPageObject.() -> Unit) =

View file

@ -0,0 +1,30 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppCurrencySelectorScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasText as withText
class AppCurrencySelectorPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AppCurrencySelectorPageObject>(semanticsProvider = semanticsProvider) {
val searchActionButton: KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.TOP_BAR_ACTION_BUTTON)
}
val searchField: KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.SEARCH_FIELD)
}
fun currencyItem(code: String): KNode = child {
hasTestTag(AppCurrencySelectorScreenTestTags.CURRENCY_ITEM)
hasAnyDescendant(withText(code, substring = true))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAppCurrencySelectorScreen(function: AppCurrencySelectorPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,20 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.AppSettingsScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AppSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AppSettingsPageObject>(semanticsProvider = semanticsProvider) {
val currencyButton: KNode = child {
hasTestTag(AppSettingsScreenTestTags.CURRENCY_BUTTON)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onAppSettingsScreen(function: AppSettingsPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -15,6 +15,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<BuyTokenPageObject>(semanticsProvider = semanticsProvider) {
@ -48,6 +49,19 @@ class BuyTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
useUnmergedTree = true useUnmergedTree = true
} }
} }
fun walletTab(walletName: String): KNode = child {
hasTestTag(BuyTokenScreenTestTags.WALLET_TAB)
hasAnyDescendant(withText(walletName))
useUnmergedTree = true
}
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitle(tokenTitle: String): LazyListItemNode = lazyList.childWith<LazyListItemNode> {
hasTestTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) = internal fun BaseTestCase.onBuyTokenScreen(function: BuyTokenPageObject.() -> Unit) =

View file

@ -2,29 +2,38 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.BuyTokenScreenTestTags import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR
/** /**
* "You receive" token chooser opened from the main-screen "Add funds" button. * Token chooser bottom sheet opened from the main-screen "Add funds" button.
*
* After the onramp redesign this is a [BaseBottomSheetTestTags.CONTAINER] bottom sheet
* (centered title + close icon), not a full screen with a top app bar.
*/ */
class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class ChooseTokenBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ChooseTokenPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<ChooseTokenBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val topAppBarTitle: KNode = child { val title: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE) hasText(getResourceString(CoreResR.string.common_choose_token))
useUnmergedTree = true useUnmergedTree = true
} }
val searchBar: KNode = child { val searchBar: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
useUnmergedTree = true
} }
fun tokenWithTitle(tokenTitle: String): KNode = child { fun tokenWithTitle(tokenTitle: String): KNode = child {
@ -35,5 +44,5 @@ class ChooseTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
} }
} }
internal fun BaseTestCase.onChooseTokenScreen(function: ChooseTokenPageObject.() -> Unit) = internal fun BaseTestCase.onChooseTokenBottomSheet(function: ChooseTokenBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function) onComposeScreen(composeTestRule, function)

View file

@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<DetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -22,17 +23,13 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.wallet_connect_title)) hasText(getResourceString(R.string.wallet_connect_title))
} }
private val walletBlock: KNode = child { val walletNameButton: KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM) hasTestTag(DetailsScreenTestTags.USER_WALLET_ITEM)
useUnmergedTree = true
} }
val walletNameButton: KNode = walletBlock.child { val addWalletButton: KNode = child {
hasClickAction() hasTestTag(DetailsScreenTestTags.ADD_WALLET_BUTTON)
hasPosition(0)
}
val scanCardButton: KNode = walletBlock.child {
hasText(getResourceString(R.string.scan_card_settings_button))
} }
val buyTangemButton: KNode = child { val buyTangemButton: KNode = child {
@ -58,6 +55,12 @@ class DetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(DetailsScreenTestTags.VERSION_NAME) hasTestTag(DetailsScreenTestTags.VERSION_NAME)
useUnmergedTree = true useUnmergedTree = true
} }
fun walletNameValue(name: String): KNode = child {
hasTestTag(DetailsScreenTestTags.SCREEN_ITEM)
hasAnyDescendant(withText(name))
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onDetailsScreen(function: DetailsPageObject.() -> Unit) = internal fun BaseTestCase.onDetailsScreen(function: DetailsPageObject.() -> Unit) =

View file

@ -13,6 +13,9 @@ import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onCompose
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasClickAction as withClickAction
import androidx.compose.ui.test.hasText as withText
import androidx.compose.ui.test.isNotEnabled as withDisabled
class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DeviceSettingsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<DeviceSettingsPageObject>(semanticsProvider = semanticsProvider) {
@ -46,6 +49,19 @@ class DeviceSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true useUnmergedTree = true
} }
val securityModeRowTitle: KNode = child {
hasTestTag(DeviceSettingsScreenTestTags.ITEM_TITLE)
hasText(getResourceString(R.string.card_settings_security_mode))
useUnmergedTree = true
}
// Match the row container (not the title Text): enabled exposes a click action, disabled exposes disabled semantics.
val securityModeRow: KNode = child {
addSemanticsMatcher(withClickAction() or withDisabled())
hasAnyDescendant(withText(getResourceString(R.string.card_settings_security_mode)))
useUnmergedTree = true
}
fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child { fun resetToFactorySettingsButtonSubtitle(withBackup: Boolean = false): KNode = child {
hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE) hasTestTag(DeviceSettingsScreenTestTags.ITEM_SUBTITLE)
useUnmergedTree = true useUnmergedTree = true

View file

@ -9,6 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<DialogPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<DialogPageObject>(semanticsProvider = semanticsProvider) {
@ -25,6 +26,17 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasTestTag(BaseDialogTestTags.TEXT) hasTestTag(BaseDialogTestTags.TEXT)
} }
val inputField: KNode = child {
hasSetTextAction()
hasAnyAncestor(withTestTag(BaseDialogTestTags.TEXT_INPUT_FIELD))
useUnmergedTree = true
}
val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_got_it))
}
val cancelButton: KNode = child { val cancelButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON) hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_cancel)) hasText(getResourceString(R.string.common_cancel))
@ -45,6 +57,16 @@ class DialogPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(getResourceString(R.string.account_details_archive_action)) hasText(getResourceString(R.string.account_details_archive_action))
} }
val discardButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_second))
}
val keepEditButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.account_unsaved_dialog_action_first))
}
val continueButton: KNode = child { val continueButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON) hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.common_continue)) hasText(getResourceString(R.string.common_continue))

View file

@ -1,10 +1,7 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.*
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.getQuantityString import com.tangem.common.extensions.getQuantityString
import com.tangem.common.extensions.hasLazyListItemPosition import com.tangem.common.extensions.hasLazyListItemPosition
@ -22,7 +19,7 @@ import androidx.compose.ui.test.hasText as withText
import com.tangem.core.res.R as CoreResR import com.tangem.core.res.R as CoreResR
import com.tangem.core.ui.R as CoreUiR import com.tangem.core.ui.R as CoreUiR
class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MainScreenPageObject>(semanticsProvider = semanticsProvider) {
private val lazyList = KLazyListNode( private val lazyList = KLazyListNode(
@ -49,32 +46,38 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val buyButton: KNode = child { val buyButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy)) hasAnyDescendant(withText(getResourceString(R.string.common_buy)))
useUnmergedTree = true
} }
val addFundsButton: KNode = child { val addFundsButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_add_funds)) hasAnyDescendant(withText(getResourceString(R.string.common_add_funds)))
useUnmergedTree = true
} }
val sendButton: KNode = child { val sendButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send)) hasAnyDescendant(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
} }
val receiveButton: KNode = child { val receiveButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive)) hasAnyDescendant(withText(getResourceString(R.string.common_receive)))
useUnmergedTree = true
} }
val sellButton: KNode = child { val transferButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell)) hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
useUnmergedTree = true
} }
val swapButton: KNode = child { val swapButton: KNode = child {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap)) hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
} }
val walletNameText: KNode = child { val walletNameText: KNode = child {
@ -87,13 +90,68 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
val walletDevicesCount: KNode = child { /**
hasTestTag(MainScreenTestTags.DEVICES_COUNT) * Collapses the collapsing header via a touch-based swipe so that items near the bottom
* of the lazy list fall within screen bounds before programmatic childWith scroll.
* Required because TangemCollapsingTopBar places the body at y=collapsingHeight, which
* pushes lower list items off-screen when the header is expanded.
*/
private fun collapseHeader() {
screenContainer {
performTouchInput { swipeUp(startY = visibleSize.height * 0.6f, endY = visibleSize.height * 0.1f) }
}
}
/** 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)),
)
}
// Wallet pager keeps the adjacent page composed (beyondViewportPageCount=1), so the token is mounted on two pages — click the displayed copy.
fun clickDisplayedToken(tokenName: String) {
val matcher = withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) and hasAnyDescendant(withText(tokenName))
val nodes = semanticsProvider.onAllNodes(matcher, useUnmergedTree = true)
for (i in 0 until nodes.fetchSemanticsNodes().size) {
if (runCatching { nodes[i].assertIsDisplayed(); nodes[i].performClick() }.isSuccess) return
}
error("Token '$tokenName' is not displayed on the current wallet page")
}
// Adjacent pager pages stay mounted; swipe the wallet card that's actually on-screen.
fun swipeToAdjacentWallet(toPrevious: Boolean) {
val nodes = semanticsProvider.onAllNodes(withTestTag(MainScreenTestTags.WALLET_LIST_ITEM))
for (i in 0 until nodes.fetchSemanticsNodes().size) {
val swiped = runCatching {
nodes[i].assertIsDisplayed()
nodes[i].performTouchInput { if (toPrevious) swipeRight() else swipeLeft() }
}.isSuccess
if (swiped) return
}
}
val restoringProgressText: KNode = child {
hasTestTag(MainScreenTestTags.SYNC_PROGRESS_TEXT)
useUnmergedTree = true
}
val walletImportedBanner: KNode = child {
hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)
useUnmergedTree = true
}
val walletImportedBannerCheckHereButton: KNode = child {
hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER))
hasText(getResourceString(CoreResR.string.main_manage_tokens))
useUnmergedTree = true useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun marketPriceBlock(): LazyListItemNode { fun marketPriceBlock(): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MarketPriceBlockTestTags.BLOCK) hasTestTag(MarketPriceBlockTestTags.BLOCK)
useUnmergedTree = true useUnmergedTree = true
@ -225,6 +283,22 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
/**
* Empty-tokens placeholder shown under an expanded account that has no tokens.
*/
val emptyAccountTokensPlaceholder: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_PLACEHOLDER)
useUnmergedTree = true
}
/**
* 'Add tokens' button inside the empty-account placeholder. Click opens manage tokens for that account.
*/
val emptyAccountAddTokensButton: KNode = child {
hasTestTag(MainScreenTestTags.EMPTY_TOKENS_ADD_BUTTON)
useUnmergedTree = true
}
/** /**
* Main account header on the main screen. Click to expand/collapse its tokens list. * Main account header on the main screen. Click to expand/collapse its tokens list.
*/ */
@ -236,6 +310,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun accountWithName(name: String): LazyListItemNode { fun accountWithName(name: String): LazyListItemNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(name)) hasAnyDescendant(withText(name))
@ -243,11 +318,21 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
} }
@OptIn(ExperimentalTestApi::class)
fun tokenRowWithTitle(tokenTitle: String): LazyListItemNode {
return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle)
useUnmergedTree = true
}
}
/** /**
* Find token list item with title and address * Find token list item with title and address
*/ */
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndAddress(tokenTitle: String): KNode { fun tokenWithTitleAndAddress(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -260,6 +345,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode { fun tokenWithCustomDerivationIcon(tokenTitle: String): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -272,6 +358,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun addAndManageButton(): KNode { fun addAndManageButton(): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON) hasTestTag(MainScreenTestTags.ADD_AND_MANAGE_BUTTON)
}.child<KNode> { }.child<KNode> {
@ -287,11 +374,17 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
val searchThroughMarketPlaceholder: KNode = child { val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title)) hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val marketsSheetDragHandle: KNode = child {
hasTestTag(MainScreenTestTags.MARKETS_SHEET_DRAG_HANDLE)
useUnmergedTree = true useUnmergedTree = true
} }
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
collapseHeader()
return lazyList.child { return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyChild(withText(tokenNetwork)) hasAnyChild(withText(tokenNetwork))
@ -301,6 +394,7 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
@OptIn(ExperimentalTestApi::class) @OptIn(ExperimentalTestApi::class)
fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode { fun tokenWithTitleAndPosition(tokenTitle: String, index: Int): KNode {
collapseHeader()
return lazyList.childWith<LazyListItemNode> { return lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM) hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasText(tokenTitle) hasText(tokenTitle)
@ -312,6 +406,46 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
} }
} }
/**
* Account row on the main screen. Tappable click to expand/collapse its tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findAccountSectionByName(accountName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Scrolls the account row into view and collapses the top bar so the account's tokens (or the
* empty placeholder) land within screen bounds after expansion. Click via [findAccountSectionByName].
*/
@OptIn(ExperimentalTestApi::class)
fun scrollToAccountSection(accountName: String) {
collapseHeader()
lazyList.childWith<LazyListItemNode> {
hasTestTag(MainScreenTestTags.ACCOUNT_LIST_ITEM)
hasAnyDescendant(withText(accountName))
useUnmergedTree = true
}
}
/**
* Find a token row on the main screen by token name. Tokens belonging to collapsed accounts
* are hidden from the semantics tree, so expanding a single account before calling this
* effectively scopes the lookup to that account's tokens.
*/
@OptIn(ExperimentalTestApi::class)
fun findTokenInAnyAccountByName(tokenName: String): KNode {
return lazyList.child {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenName))
useUnmergedTree = true
}
}
fun KNode.assertIsUnreachable() { fun KNode.assertIsUnreachable() {
this { this {
hasAnyAncestor(withText(getResourceString(R.string.common_unreachable))) hasAnyAncestor(withText(getResourceString(R.string.common_unreachable)))
@ -324,16 +458,25 @@ class MainScreenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
* Tests will fail if assertIsNotDisplayed() or assertDoesNotExist() are used instead. * Tests will fail if assertIsNotDisplayed() or assertDoesNotExist() are used instead.
*/ */
fun assertTokenDoesNotExist(tokenTitle: String) { fun assertTokenDoesNotExist(tokenTitle: String) {
try { lazyList.child<KNode> {
tokenWithTitleAndAddress(tokenTitle).assertExists() hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
throw AssertionError("Token with title '$tokenTitle' should not exist but was found") hasAnyDescendant(withText(tokenTitle))
} catch (e: AssertionError) { useUnmergedTree = true
if (e.message?.contains("No node found") == true) { }.assertDoesNotExist()
return }
} else {
throw e fun assertTokensCount(expectedCount: Int) {
} semanticsProvider
} .onAllNodes(withTestTag(MainScreenTestTags.TOKEN_LIST_ITEM), useUnmergedTree = true)
.assertCountEquals(expectedCount)
}
fun assertTokenExists(tokenTitle: String) {
lazyList.child<KNode> {
hasTestTag(MainScreenTestTags.TOKEN_LIST_ITEM)
hasAnyDescendant(withText(tokenTitle))
useUnmergedTree = true
}.assertIsDisplayed()
} }
} }

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.BaseSearchBarTestTags import com.tangem.core.ui.test.BaseSearchBarTestTags
import com.tangem.core.ui.test.ManageTokensScreenTestTags import com.tangem.core.ui.test.ManageTokensScreenTestTags
import com.tangem.core.ui.test.SwitchTestTags import com.tangem.core.ui.test.SwitchTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
@ -20,6 +21,16 @@ import androidx.compose.ui.test.hasAnyAncestor as withAnyAncestor
class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class ManageTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<ManageTokensPageObject>(semanticsProvider = semanticsProvider) {
val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
val topAppBarTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(com.tangem.core.ui.R.string.add_tokens_title))
useUnmergedTree = true
}
val searchField: KNode = child { val searchField: KNode = child {
hasTestTag(BaseSearchBarTestTags.SEARCH_BAR) hasTestTag(BaseSearchBarTestTags.SEARCH_BAR)
} }

View file

@ -2,11 +2,9 @@ package com.tangem.screens
import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasParent
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TokenElementsTestTags import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -23,16 +21,15 @@ class MarketsExchangesPageObject(private val provider: SemanticsNodeInteractions
fun allExchangeTypeNodes(): List<SemanticsNode> = fun allExchangeTypeNodes(): List<SemanticsNode> =
provider provider
.onAllNodes(hasParent(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_PRICE)))) .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_PRICE))
.fetchSemanticsNodes() .fetchSemanticsNodes()
fun allTrustScoreNodes(): List<SemanticsNode> = fun allTrustScoreNodes(): List<SemanticsNode> =
provider provider
.onAllNodes(hasParent(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))) .onAllNodes(hasTestTag(TokenElementsTestTags.TOKEN_CRYPTO_AMOUNT))
.fetchSemanticsNodes() .fetchSemanticsNodes()
val exchangesTitle: KNode = child { val exchangesTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.markets_token_details_exchanges_title)) hasText(getResourceString(R.string.markets_token_details_exchanges_title))
useUnmergedTree = true useUnmergedTree = true
} }

View file

@ -1,6 +1,8 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasTestTag
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX import com.tangem.common.constants.TestConstants.MARKETS_MAIN_NETWORK_SUFFIX
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
@ -15,9 +17,9 @@ import io.github.kakaocup.kakao.common.utilities.getResourceString
class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MarketsPageObject>(semanticsProvider = semanticsProvider) {
val addToPortfolioButton: KNode = child { val addButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(BaseButtonTestTags.TEXT)
hasText(getResourceString(R.string.common_add_to_portfolio)) hasText(getResourceString(R.string.common_add))
useUnmergedTree = true useUnmergedTree = true
} }
@ -31,7 +33,12 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
} }
val searchThroughMarketPlaceholder: KNode = child { val searchThroughMarketPlaceholder: KNode = child {
hasText(getResourceString(R.string.markets_search_header_title)) hasText(getResourceString(R.string.markets_search_title_placeholder))
useUnmergedTree = true
}
val tokenDetailsContent: KNode = child {
hasTestTag(MarketsTestTags.TOKEN_DETAILS_CONTENT)
useUnmergedTree = true useUnmergedTree = true
} }
@ -41,7 +48,8 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
} }
val listedOnBlockContainer: KNode = child { val listedOnBlockContainer: KNode = child {
hasText(getResourceString(R.string.markets_token_details_listed_on), substring = true) hasTestTag(MarketsTestTags.LISTED_ON_BLOCK)
useUnmergedTree = true
} }
val listedOnEmptyText: KNode = child { val listedOnEmptyText: KNode = child {
@ -60,6 +68,13 @@ class MarketsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
hasText(title) hasText(title)
} }
} }
@ExperimentalTestApi
fun scrollToListedOnBlock() {
tokenDetailsContent {
performScrollToNode(hasTestTag(MarketsTestTags.LISTED_ON_BLOCK))
}
}
} }
internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) = internal fun BaseTestCase.onMarketsScreen(function: MarketsPageObject.() -> Unit) =

View file

@ -3,14 +3,13 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags import com.tangem.core.ui.test.MarketTokenDetailsBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.features.onramp.impl.R import com.tangem.features.onramp.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
import com.tangem.core.ui.R as CoreUiR
class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<MarketsTokenDetailsPageObject>(semanticsProvider = semanticsProvider) {
@ -20,11 +19,14 @@ class MarketsTokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractions
hasText(getResourceString(R.string.common_swap), substring = true) hasText(getResourceString(R.string.common_swap), substring = true)
} }
val inYourPortfolioBlock: KNode = child {
hasText(getResourceString(CoreUiR.string.markets_portfolio_block_subtitle), substring = true)
useUnmergedTree = true
}
fun tokenWithTitle(title: String): KNode = child { fun tokenWithTitle(title: String): KNode = child {
hasAnyAncestor(withTestTag(MarketTokenDetailsBottomSheetTestTags.PORTFOLIO_TOKEN_ITEM))
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnySibling(withTestTag(TokenElementsTestTags.TOKEN_ICON))
hasAnyChild(withText(title)) hasAnyChild(withText(title))
hasClickAction()
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -28,23 +28,18 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
useUnmergedTree = true useUnmergedTree = true
} }
private val topBarGroupButton: KNode = child { val organizeMenuButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_BUTTON) hasTestTag(OrganizeTokensScreenTestTags.MENU_BUTTON)
useUnmergedTree = true useUnmergedTree = true
} }
val groupButton: KNode = topBarGroupButton.child { val groupButton: KNode = child {
hasText(getResourceString(R.string.organize_tokens_group)) hasText(getResourceString(R.string.organize_tokens_group))
useUnmergedTree = true useUnmergedTree = true
} }
val ungroupButton: KNode = topBarGroupButton.child {
hasText(getResourceString(R.string.organize_tokens_ungroup))
useUnmergedTree = true
}
val sortByBalanceButton: KNode = child { val sortByBalanceButton: KNode = child {
hasTestTag(OrganizeTokensScreenTestTags.SORT_BY_BALANCE_BUTTON) hasText(getResourceString(R.string.organize_tokens_sort_by_balance))
useUnmergedTree = true useUnmergedTree = true
} }
// endregion TopBar // endregion TopBar
@ -84,7 +79,7 @@ class OrganizeTokensPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
fun tokenNetworkGroupTitle(tokenNetwork: String): KNode { fun tokenNetworkGroupTitle(tokenNetwork: String): KNode {
return lazyList.child { return lazyList.child {
hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM) hasTestTag(OrganizeTokensScreenTestTags.GROUP_TITLE_ITEM)
hasAnyChild(withText(tokenNetwork)) hasAnyDescendant(withText(tokenNetwork))
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -0,0 +1,27 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
class SecurityModePageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SecurityModePageObject>(semanticsProvider = semanticsProvider) {
// Description only appears on the Security Mode screen — unambiguous "screen opened" signal.
val longTapOptionDescription: KNode = child {
hasText(getResourceString(R.string.details_manage_security_long_tap_description))
useUnmergedTree = true
}
val saveChangesButton: KNode = child {
hasText(getResourceString(R.string.common_save_changes))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSecurityModeScreen(function: SecurityModePageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -6,7 +6,7 @@ import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.FooterTestTags import com.tangem.core.ui.test.FooterTestTags
import com.tangem.core.ui.test.SendAddressScreenTestTags import com.tangem.core.ui.test.SendAddressScreenTestTags
import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.features.send.v2.impl.R import com.tangem.features.send.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
@ -97,7 +97,7 @@ class SendAddressPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
): KNode = child { ): KNode = child {
hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM) hasTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ITEM)
hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON)) hasAnyChild(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_ICON))
hasAnyDescendant(withText(recipientAddress)) hasAnyDescendant(withText(recipientAddress, substring = true))
hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT)) hasAnyDescendant(withTestTag(SendAddressScreenTestTags.RECENT_ADDRESS_TEXT))
useUnmergedTree = true useUnmergedTree = true
if (description != null) { if (description != null) {

View file

@ -84,6 +84,18 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
fun warningMessageContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.MESSAGE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningTitleContaining(textPart: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(textPart, substring = true)
useUnmergedTree = true
}
fun warningIcon(message: String): KNode = child { fun warningIcon(message: String): KNode = child {
hasTestTag(NotificationTestTags.ICON) hasTestTag(NotificationTestTags.ICON)
hasAnySibling(withText(message)) hasAnySibling(withText(message))
@ -96,6 +108,16 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
val recipientBlock: KNode = child {
hasTestTag(SendConfirmScreenTestTags.RECIPIENT_BLOCK)
useUnmergedTree = true
}
val recipientMemo: KNode = child {
hasTestTag(SendConfirmScreenTestTags.RECIPIENT_MEMO)
useUnmergedTree = true
}
val provider: KNode = child { val provider: KNode = child {
hasText(getResourceString(R.string.express_provider)) hasText(getResourceString(R.string.express_provider))
useUnmergedTree = true useUnmergedTree = true
@ -145,6 +167,12 @@ class SendConfirmPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
fun feeBlockCurrency(symbol: String): KNode = child {
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
hasAnyDescendant(withText(symbol))
useUnmergedTree = true
}
val refreshButton: KNode = child { val refreshButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON) hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(CoreUiR.string.warning_button_refresh)) hasText(getResourceString(CoreUiR.string.warning_button_refresh))

View file

@ -0,0 +1,65 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.SelectNetworkFeeBottomSheetTestTags
import com.tangem.core.ui.test.TokenElementsTestTags
import com.tangem.wallet.R
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
/**
* Gasless fee selector modal: the `NetworkFee` route (fee-paying token row + selected speed) and the
* `ChooseToken` route. The `ChooseSpeed` route is covered by [SendSelectNetworkFeeBottomSheetPageObject].
*/
class SendFeeSelectorBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<SendFeeSelectorBottomSheetPageObject>(semanticsProvider = semanticsProvider) {
val networkFeeTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.common_network_fee_title))
useUnmergedTree = true
}
val chooseTokenTitle: KNode = child {
hasTestTag(BaseBottomSheetTestTags.TITLE)
hasText(getResourceString(R.string.fee_selector_choose_token_title))
useUnmergedTree = true
}
val feeTokenRow: KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
useUnmergedTree = true
}
fun feeTokenItem(tokenName: String): KNode = child {
hasTestTag(TokenElementsTestTags.TOKEN_TITLE)
hasAnyChild(withText(tokenName))
useUnmergedTree = true
}
fun feeSpeedItemTitle(speed: String): KNode = child {
hasTestTag(SelectNetworkFeeBottomSheetTestTags.REGULAR_ITEM_TITLE)
hasText(speed)
useUnmergedTree = true
}
val applyButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_apply)))
useUnmergedTree = true
}
val notEnoughFundsError: KNode = child {
hasText(getResourceString(R.string.gasless_not_enough_funds_to_cover_token_fee))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onSendFeeSelectorBottomSheet(function: SendFeeSelectorBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -9,7 +9,7 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import com.tangem.features.send.v2.impl.R as SendR import com.tangem.features.send.impl.R as SendR
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class SendPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :

View file

@ -20,6 +20,13 @@ class SwapSuccessPageObject(semanticsProvider: SemanticsNodeInteractionsProvider
useUnmergedTree = true useUnmergedTree = true
} }
// App Transfers reuses the swap success screen; in transfer mode its title is "Transfer in progress".
val transferInProgressTitle: KNode = child {
hasTestTag(TransactionSuccessScreenTestTags.TITLE)
hasText(getResourceString(R.string.transfer_in_progress_title))
useUnmergedTree = true
}
val closeButton: KNode = child { val closeButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON) hasTestTag(BaseButtonTestTags.BUTTON)
hasAnyDescendant(withText(getResourceString(R.string.common_close))) hasAnyDescendant(withText(getResourceString(R.string.common_close)))

View file

@ -54,6 +54,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
fun feeBlockCurrency(symbol: String): KNode = child {
hasTestTag(FeeSelectorBlockTestTags.SELECTOR_BLOCK)
hasAnyDescendant(withText(symbol))
useUnmergedTree = true
}
val receiveAmountShimmer: KNode = child { val receiveAmountShimmer: KNode = child {
hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER) hasTestTag(SwapTokenScreenTestTags.RECEIVE_AMOUNT_SHIMMER)
} }
@ -72,6 +78,11 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
val bestRateText: KNode = child {
hasText(getResourceString(R.string.express_provider_best_rate))
useUnmergedTree = true
}
val errorNotificationTitle: KNode = child { val errorNotificationTitle: KNode = child {
hasTestTag(NotificationTestTags.TITLE) hasTestTag(NotificationTestTags.TITLE)
useUnmergedTree = true useUnmergedTree = true
@ -119,6 +130,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
useUnmergedTree = true useUnmergedTree = true
} }
fun insufficientFeeForTransferNotificationTitle(feeCoinName: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE)
hasText(getResourceString(R.string.warning_send_blocked_funds_for_fee_title, feeCoinName))
useUnmergedTree = true
}
fun warningTitle(title: String): KNode = child { fun warningTitle(title: String): KNode = child {
hasTestTag(NotificationTestTags.TITLE) hasTestTag(NotificationTestTags.TITLE)
hasText(title) hasText(title)
@ -147,6 +164,23 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(R.string.common_swap)) hasText(getResourceString(R.string.common_swap))
} }
val transferButton: KNode = child {
hasTestTag(BaseButtonTestTags.BUTTON)
hasText(getResourceString(R.string.swapping_transfer_action))
}
val transferTitle: KNode = child {
hasTestTag(TopAppBarTestTags.TITLE)
hasText(getResourceString(R.string.common_transfer))
useUnmergedTree = true
}
// PercentPill testTag is the PredefinedPercentAmount enum name; MAX == "MAX".
val maxAmountButton: KNode = child {
hasTestTag("MAX")
useUnmergedTree = true
}
val youSwapBlock: KNode = child { val youSwapBlock: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER) hasTestTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER)
hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2))) hasAnyDescendant(withText(getResourceString(R.string.swapping_from_title_v2)))
@ -178,6 +212,7 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
val swapFiatAmount: KNode = child { val swapFiatAmount: KNode = child {
hasTestTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT) hasTestTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT)
useUnmergedTree = true
} }
val swapSelectTokenIcon: KNode = child { val swapSelectTokenIcon: KNode = child {
@ -210,6 +245,12 @@ class SwapTokenPageObject(semanticsProvider: SemanticsNodeInteractionsProvider)
hasText(getResourceString(R.string.common_choose_token)) hasText(getResourceString(R.string.common_choose_token))
useUnmergedTree = true useUnmergedTree = true
} }
// Transfer mode auto-fills the memo/destination tag — the manual Send-address field must never render here.
val destinationTagField: KNode = child {
hasTestTag(SendAddressScreenTestTags.DESTINATION_TAG_TEXT_FIELD)
useUnmergedTree = true
}
} }
internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) = internal fun BaseTestCase.onSwapTokenScreen(function: SwapTokenPageObject.() -> Unit) =

View file

@ -1,20 +1,17 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags import com.tangem.core.ui.test.BaseActionButtonsBlockTestTags
import com.tangem.core.ui.test.BaseButtonTestTags import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.NotificationTestTags import com.tangem.core.ui.test.NotificationTestTags
import com.tangem.core.ui.test.TokenDetailsScreenTestTags import com.tangem.core.ui.test.TokenDetailsScreenTestTags
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import com.tangem.features.tokendetails.impl.R import com.tangem.features.tokendetails.impl.R
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
@ -36,18 +33,8 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true useUnmergedTree = true
} }
val availableStakingBlockTitle: KNode = child { fun availableStakingBlockText(apy: String): KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TITLE) hasText(getResourceString(R.string.token_details_earn_staking_subtitle, apy))
useUnmergedTree = true
}
val availableStakingBlockText: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_SERVICE_TEXT)
useUnmergedTree = true
}
val availableStakingBlockCurrencyIcon: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CURRENCY_ICON)
useUnmergedTree = true useUnmergedTree = true
} }
@ -62,69 +49,45 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
useUnmergedTree = true useUnmergedTree = true
} }
val stakingDot: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_DOT)
useUnmergedTree = true
}
val stakingTokenAmount: KNode = child { val stakingTokenAmount: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT) hasTestTag(TokenDetailsScreenTestTags.STAKING_TOKEN_AMOUNT)
useUnmergedTree = true useUnmergedTree = true
} }
val stakingChevronIcon: KNode = child { val stakingTitle: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.STAKING_CHEVRON_ICON) hasText(getResourceString(R.string.common_staking))
useUnmergedTree = true
} }
val stakingTitle: KNode = child { val stakingEnabledTitle: KNode = child {
hasText(getResourceString(R.string.staking_native)) hasText(getResourceString(R.string.staking_enabled))
} }
val title: KNode = child { val title: KNode = child {
hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE) hasTestTag(TokenDetailsScreenTestTags.TOKEN_TITLE)
} }
private val horizontalActionChips = KLazyListNode( val fiatBalance: KNode = child {
semanticsProvider = semanticsProvider, hasAnyAncestor(withTestTag(TokenDetailsScreenTestTags.BALANCE_FIAT))
viewBuilderAction = { hasTestTag(BaseActionButtonsBlockTestTags.HORIZONTAL_ACTION_CHIPS) }, addSemanticsMatcher(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text))
itemTypeBuilder = { itemType(::LazyListItemNode) }, useUnmergedTree = true
positionMatcher = { position ->
SemanticsMatcher.expectValue(
LazyListItemPositionSemantics,
position
)
}
)
@OptIn(ExperimentalTestApi::class)
fun receiveButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_receive))
} }
@OptIn(ExperimentalTestApi::class) val addFundsButton: KNode = child {
fun swapButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_swap)) hasAnyDescendant(withText(getResourceString(R.string.tangempay_card_details_add_funds)))
useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) val swapButton: KNode = child {
fun sellButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_sell)) hasAnyDescendant(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
} }
@OptIn(ExperimentalTestApi::class) val transferButton: KNode = child {
fun buyButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON) hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_buy)) hasAnyDescendant(withText(getResourceString(R.string.common_transfer)))
} useUnmergedTree = true
@OptIn(ExperimentalTestApi::class)
fun sendButton(): LazyListItemNode = horizontalActionChips.childWith<LazyListItemNode> {
hasTestTag(BaseActionButtonsBlockTestTags.ACTION_BUTTON)
hasText(getResourceString(R.string.common_send))
} }
fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child { fun networkFeeNotificationIcon(feeCurrencyName: String): KNode = child {
@ -204,7 +167,6 @@ class TokenDetailsPageObject(semanticsProvider: SemanticsNodeInteractionsProvide
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_SWAP_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_ICON))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT)) hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_TO_AMOUNT))
hasAnyDescendant(withTestTag(TokenDetailsScreenTestTags.EXPRESS_STATUS_ITEM_CHEVRON_ICON))
useUnmergedTree = true useUnmergedTree = true
} }
} }

View file

@ -0,0 +1,41 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasText as withText
class TransferBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TransferBottomSheetPageObject>(
semanticsProvider = semanticsProvider,
viewBuilderAction = { hasTestTag(BaseBottomSheetTestTags.CONTAINER) },
) {
val sendButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_send)))
useUnmergedTree = true
}
val swapButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_swap)))
useUnmergedTree = true
}
val sellButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_sell)))
useUnmergedTree = true
}
val closeButton: KNode = child {
hasAnyChild(withText(getResourceString(R.string.common_close)))
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTransferBottomSheet(function: TransferBottomSheetPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -0,0 +1,37 @@
package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TransactionHistoryItemTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
import androidx.compose.ui.test.hasText as withText
class TxHistoryPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<TxHistoryPageObject>(semanticsProvider = semanticsProvider) {
fun transactionItem(title: String): KNode = child {
hasTestTag(TransactionHistoryItemTestTags.ITEM)
hasAnyDescendant(withText(title))
useUnmergedTree = true
}
fun transactionAmount(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.AMOUNT)
useUnmergedTree = true
}
fun transactionCurrency(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.CURRENCY)
useUnmergedTree = true
}
fun transactionConfirmedStatus(title: String): KNode = transactionItem(title).child {
hasTestTag(TransactionHistoryItemTestTags.STATUS_CONFIRMED)
useUnmergedTree = true
}
}
internal fun BaseTestCase.onTxHistoryScreen(function: TxHistoryPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -1,5 +1,6 @@
package com.tangem.screens package com.tangem.screens
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags import com.tangem.core.ui.test.TopAppBarTestTags
@ -9,11 +10,16 @@ import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode import io.github.kakaocup.compose.node.element.KNode
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
import androidx.compose.ui.test.hasTestTag as withTestTag
import androidx.compose.ui.test.hasText as withText import androidx.compose.ui.test.hasText as withText
class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) : class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<WalletSettingsPageObject>(semanticsProvider = semanticsProvider) { ComposeScreen<WalletSettingsPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER)
}
val topAppBarBackButton: KNode = child { val topAppBarBackButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON) hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
} }
@ -22,6 +28,31 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM) hasTestTag(WalletSettingsScreenTestTags.SCREEN_ITEM)
} }
// The Accounts section loads async and can push rows below the fold — scroll before asserting/clicking.
private val scrollableContainer: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.SCREEN_CONTAINER)
}
@OptIn(ExperimentalTestApi::class)
fun scrollToText(text: String) = scrollableContainer { performScrollToNode(withText(text)) }
@OptIn(ExperimentalTestApi::class)
fun scrollToDeviceSettings() = scrollToText(getResourceString(R.string.card_settings_title))
@OptIn(ExperimentalTestApi::class)
fun scrollToLinkMoreCards() = scrollToText(getResourceString(R.string.details_row_title_create_backup))
@OptIn(ExperimentalTestApi::class)
fun scrollToReferralProgram() = scrollToText(getResourceString(R.string.details_referral_title))
@OptIn(ExperimentalTestApi::class)
fun scrollToForgetWallet() = scrollToText(getResourceString(R.string.settings_forget_wallet))
@OptIn(ExperimentalTestApi::class)
fun scrollToRenameButton() = scrollableContainer {
performScrollToNode(withTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON))
}
val linkMoreCardsButton: KNode = walletSettingsItem.child { val linkMoreCardsButton: KNode = walletSettingsItem.child {
hasText(getResourceString(R.string.details_row_title_create_backup)) hasText(getResourceString(R.string.details_row_title_create_backup))
} }
@ -38,6 +69,16 @@ class WalletSettingsPageObject(semanticsProvider: SemanticsNodeInteractionsProvi
hasText(getResourceString(R.string.settings_forget_wallet)) hasText(getResourceString(R.string.settings_forget_wallet))
} }
val renameWalletButton: KNode = child {
hasTestTag(WalletSettingsScreenTestTags.RENAME_BUTTON)
useUnmergedTree = true
}
fun walletNameValue(name: String): KNode = walletSettingsItem.child {
hasText(name)
useUnmergedTree = true
}
val accountsListContainer: KNode = walletSettingsItem.child { val accountsListContainer: KNode = walletSettingsItem.child {
hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER) hasTestTag(WalletSettingsScreenTestTags.ACCOUNTS_CONTAINER)
} }

View file

@ -3,7 +3,6 @@ package com.tangem.screens
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.core.ui.R import com.tangem.core.ui.R
import com.tangem.core.ui.test.BaseButtonTestTags
import com.tangem.core.ui.test.WarningBottomSheetTestTags import com.tangem.core.ui.test.WarningBottomSheetTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
@ -31,27 +30,23 @@ class WarningBottomSheetPageObject(semanticsProvider: SemanticsNodeInteractionsP
} }
val okGotItButton: KNode = child { val okGotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY)
hasText(getResourceString(R.string.warning_button_ok)) hasText(getResourceString(R.string.warning_button_ok))
useUnmergedTree = true
} }
val gotItButton: KNode = child { val gotItButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
hasText(getResourceString(R.string.common_got_it)) hasText(getResourceString(R.string.common_got_it))
useUnmergedTree = true
} }
val cancelButton: KNode = child { val cancelButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(WarningBottomSheetTestTags.BUTTON_PRIMARY)
hasText(getResourceString(R.string.common_cancel)) hasText(getResourceString(R.string.common_cancel))
useUnmergedTree = true
} }
val connectAnywayButton: KNode = child { val connectAnywayButton: KNode = child {
hasTestTag(BaseButtonTestTags.TEXT) hasTestTag(WarningBottomSheetTestTags.BUTTON_SECONDARY)
hasText(getResourceString(R.string.wc_alert_connect_anyway)) hasText(getResourceString(R.string.wc_alert_connect_anyway))
useUnmergedTree = true
} }
} }

View file

@ -0,0 +1,45 @@
package com.tangem.screens.accounts
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import com.tangem.common.BaseTestCase
import com.tangem.core.ui.test.TopAppBarTestTags
import com.tangem.core.ui.test.accounts.AccountInfoEditScreenTestTags
import io.github.kakaocup.compose.node.element.ComposeScreen
import io.github.kakaocup.compose.node.element.ComposeScreen.Companion.onComposeScreen
import io.github.kakaocup.compose.node.element.KNode
class AccountInfoEditPageObject(semanticsProvider: SemanticsNodeInteractionsProvider) :
ComposeScreen<AccountInfoEditPageObject>(semanticsProvider = semanticsProvider) {
val screenContainer: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.ACCOUNT_DETAILS_CONTAINER)
}
val accountNameField: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.NAME_FIELD)
}
val accountCurrentIcon: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SELECTED_ICON)
}
val accountColorOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.COLOR_OPTION)
}
val accountTypeOption: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.TYPE_OPTION)
}
val saveAccountButton: KNode = child {
hasTestTag(AccountInfoEditScreenTestTags.SAVE_ACCOUNT_BUTTON)
}
val crossButton: KNode = child {
hasTestTag(TopAppBarTestTags.CLOSE_BUTTON)
}
}
internal fun BaseTestCase.onAccountInfoEditorScreen(function: AccountInfoEditPageObject.() -> Unit) =
onComposeScreen(composeTestRule, function)

View file

@ -26,7 +26,7 @@ class TangemPayCardPagePageObject(semanticsProvider: SemanticsNodeInteractionsPr
} }
val showDetailsButton: KNode = child { val showDetailsButton: KNode = child {
hasTestTag(TangemPayTestTags.CARD_DETAILS_SHOW_BUTTON) hasTestTag(TangemPayTestTags.SHOW_DETAILS_ROW)
useUnmergedTree = true useUnmergedTree = true
} }

View file

@ -0,0 +1,90 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class AppCurrencyTest : BaseTestCase() {
@AllureId("781")
@DisplayName("App Currency: change of equivalent")
@Test
fun changeAppCurrencyTest() {
val currenciesScenario = "currencies_api"
val appSettingsState = "AppSettings"
val targetCurrency = "EUR"
val targetSymbol = ""
val token = "Bitcoin"
setupHooks(
additionalAfterSection = { resetWireMockScenarioState(currenciesScenario) },
).run {
step("Set WireMock scenario '$currenciesScenario' to '$appSettingsState'") {
setWireMockScenarioState(scenarioName = currenciesScenario, state = appSettingsState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
synchronizeAddresses()
step("Open wallet details") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Click on 'App settings' button") {
onDetailsScreen { appSettingsButton.clickWithAssertion() }
}
step("Click on 'App currency' button") {
onAppSettingsScreen { currencyButton.clickWithAssertion() }
}
step("Click on search button") {
onAppCurrencySelectorScreen { searchActionButton.clickWithAssertion() }
}
step("Search currency '$targetCurrency'") {
onAppCurrencySelectorScreen { searchField.performTextInput(targetCurrency) }
}
step("Click on currency '$targetCurrency'") {
onAppCurrencySelectorScreen { currencyItem(targetCurrency).performClick() }
}
step("Assert 'App settings' screen is open after currency selection") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onAppSettingsScreen { currencyButton.assertIsDisplayed() }
}
}
step("Return to 'Details' screen") {
waitForIdle()
device.uiDevice.pressBack()
}
step("Return to 'Main' screen via 'Back' button") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Main' screen is opened") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { screenContainer.assertIsDisplayed() }
}
}
step("Assert total balance contains '$targetSymbol' on 'Main' screen") {
// Balance re-loads in the new currency async after the switch — wait for the € equivalent.
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onMainScreen { totalBalanceText.assertTextContains(targetSymbol, substring = true) }
}
}
step("Click on token '$token'") {
onMainScreen { tokenWithTitleAndAddress(token).clickWithAssertion() }
}
step("Assert token fiat balance contains '$targetSymbol'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { fiatBalance.assertTextContains(targetSymbol, substring = true) }
}
}
}
}
}

View file

@ -44,8 +44,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }
@ -91,8 +91,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }
@ -165,8 +165,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }
@ -251,8 +251,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }
@ -336,8 +336,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }
@ -425,8 +425,8 @@ class BuyTokenTest : BaseTestCase() {
onMainScreen { addFundsButton.clickWithAssertion() } onMainScreen { addFundsButton.clickWithAssertion() }
} }
step("Click on token with name: '$tokenTitle'") { step("Click on token with name: '$tokenTitle'") {
onChooseTokenScreen { onChooseTokenBottomSheet {
topAppBarTitle.assertIsDisplayed() title.assertIsDisplayed()
tokenWithTitle(tokenTitle).clickWithAssertion() tokenWithTitle(tokenTitle).clickWithAssertion()
} }
} }

View file

@ -5,14 +5,21 @@ import com.tangem.common.extensions.clickWithAssertion
import com.tangem.domain.models.scan.ProductType import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.screens.* import com.tangem.screens.*
import com.tangem.tap.domain.sdk.mocks.content.Firmware412MockContent
import com.tangem.tap.domain.sdk.mocks.content.S2CMockContent
import com.tangem.tap.domain.sdk.mocks.content.SingleCurrencyMockContent
import com.tangem.tap.domain.sdk.mocks.content.V3MockContent
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Ignore
import org.junit.Test import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class DetailsTest : BaseTestCase() { class DetailsTest : BaseTestCase() {
@AllureId("836")
@DisplayName("Details: (Wallet) fields")
@Test @Test
fun walletWithoutBackupDetailsTest() = fun walletWithoutBackupDetailsTest() =
setupHooks().run { setupHooks().run {
@ -46,70 +53,26 @@ class DetailsTest : BaseTestCase() {
} }
onWalletSettingsScreen { onWalletSettingsScreen {
step("Assert 'Link more cards' button is visible") { step("Assert 'Link more cards' button is visible") {
scrollToLinkMoreCards()
linkMoreCardsButton.assertIsDisplayed() linkMoreCardsButton.assertIsDisplayed()
} }
step("Assert 'Card Settings' button is visible") { step("Assert 'Card Settings' button is visible") {
scrollToDeviceSettings()
deviceSettingsButton.assertIsDisplayed() deviceSettingsButton.assertIsDisplayed()
} }
step("Assert 'Referral program' button is visible") { step("Assert 'Referral program' button is visible") {
scrollToReferralProgram()
referralProgramButton.assertIsDisplayed() referralProgramButton.assertIsDisplayed()
} }
step("Assert 'Forget wallet' button is visible") { step("Assert 'Forget wallet' button is visible") {
scrollToForgetWallet()
forgetWalletButton.assertIsDisplayed() forgetWalletButton.assertIsDisplayed()
} }
} }
} }
// @Test @AllureId("837")
fun wallet2DetailsTest() = @DisplayName("Details: (Note) fields")
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Wallet2)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is visible") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Scan card' button is visible") {
scanCardButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms or service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Open 'Wallet settings' screen") {
walletNameButton.clickWithAssertion()
}
}
onWalletSettingsScreen {
step("Assert 'Link more cards' button does not exist") {
linkMoreCardsButton.assertIsNotDisplayed()
}
step("Assert 'Card Settings' button is visible") {
deviceSettingsButton.assertIsDisplayed()
}
step("Assert 'Referral program' button is visible") {
referralProgramButton.assertIsDisplayed()
}
step("Assert 'Forget wallet' button is visible") {
forgetWalletButton.assertIsDisplayed()
}
}
}
@Test @Test
fun noteDetailsTest() = fun noteDetailsTest() =
setupHooks().run { setupHooks().run {
@ -154,6 +117,214 @@ class DetailsTest : BaseTestCase() {
} }
} }
@AllureId("840")
@DisplayName("Details: (Twins) fields")
@Test
fun twinsDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Twins, isTwinsCard = true)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button does not exist") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Assert app version is visible") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("839")
@DisplayName("Details: (v4.12) fields")
@Test
fun firmware412DetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = Firmware412MockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is visible") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is visible") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is visible") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is visible") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is visible") {
toSButton.assertIsDisplayed()
}
step("Assert app version is visible") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("838")
@DisplayName("Details: (v3 multicurrency) fields")
@Test
fun v3MultiCurrencyDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = V3MockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is displayed") {
walletConnectButton.assertIsDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("9832")
@DisplayName("Details: (single currency) fields")
@Test
fun singleCurrencyDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = SingleCurrencyMockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
@AllureId("841")
@DisplayName("Details: (S2C) fields")
@Test
fun s2cDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = S2CMockContent)
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
step("Assert 'Buy Tangem card' button is displayed") {
buyTangemButton.assertIsDisplayed()
}
step("Assert 'App settings' button is displayed") {
appSettingsButton.assertIsDisplayed()
}
step("Assert 'Contact support' button is displayed") {
contactSupportButton.assertIsDisplayed()
}
step("Assert 'Terms of service' button is displayed") {
toSButton.assertIsDisplayed()
}
step("Assert app version is displayed") {
versionName.assertIsDisplayed()
}
}
}
// Parked: createWalletActions adds Sell for single-wallet cards with no isStart2Coin() check.
@Ignore("[REDACTED_JIRA]")
@AllureId("2869")
@DisplayName("Details: (S2C) no trade buttons and standard details")
@Test
fun s2cNoTradeButtonsDetailsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(mockContent = S2CMockContent)
}
onMainScreen {
step("Assert 'Buy' button is not displayed") {
buyButton.assertIsNotDisplayed()
}
step("Assert 'Transfer' button is not displayed") {
transferButton.assertIsNotDisplayed()
}
step("Assert 'Swap' button is not displayed") {
swapButton.assertIsNotDisplayed()
}
}
onMainScreenTopBar {
step("Open wallet details") {
moreButton.clickWithAssertion()
}
}
onDetailsScreen {
step("Assert 'Wallet connect' button is not displayed") {
walletConnectButton.assertIsNotDisplayed()
}
}
}
@AllureId("3647") @AllureId("3647")
@DisplayName("Referral program: validate screen") @DisplayName("Referral program: validate screen")
@Test @Test
@ -199,4 +370,32 @@ class DetailsTest : BaseTestCase() {
onReferralProgramScreen { participateButton.assertIsDisplayed() } onReferralProgramScreen { participateButton.assertIsDisplayed() }
} }
} }
@AllureId("222")
@DisplayName("Details: ToS screen opening")
@Test
fun tosScreenOpeningTest() {
val tosUrl = "https://tangem.com/tangem_tos.html"
setupHooks().run {
step("Open 'Main screen'") {
openMainScreen()
}
step("Open wallet details") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'ToS' screen") {
onDetailsScreen { toSButton.clickWithAssertion() }
}
step("Verify 'ToS' screen opened") {
onDisclaimerScreen {
title.assertIsDisplayed()
webView.assertIsDisplayed()
webView.assertContentDescriptionContains(tosUrl, true)
}
}
}
}
} }

View file

@ -27,6 +27,7 @@ import com.tangem.screens.onSendScreen
import com.tangem.screens.onStoriesScreen import com.tangem.screens.onStoriesScreen
import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onMainScreenTopBar import com.tangem.screens.onMainScreenTopBar
import com.tangem.screens.onTransferBottomSheet
import com.tangem.tap.domain.sdk.mocks.MockProvider import com.tangem.tap.domain.sdk.mocks.MockProvider
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
@ -94,8 +95,11 @@ class FeedbackTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$sendAmount' in input text field") { step("Type '$sendAmount' in input text field") {
onSendScreen { onSendScreen {

View file

@ -22,7 +22,8 @@ class OrganizeTokensTest : BaseTestCase() {
fun groupTokensTest() { fun groupTokensTest() {
setupHooks().run { setupHooks().run {
val tokenTitle = "Ethereum" val tokenTitle = "Ethereum"
val tokenNetwork = "Ethereum network" val networkTitleOrganize = "Ethereum"
val networkTitleMain = "Ethereum network"
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
@ -39,17 +40,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed() tokenWithTitle(tokenTitle).assertIsDisplayed()
} }
} }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' button") { step("Click 'Group' button") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() } onOrganizeTokensScreen { groupButton.clickWithAssertion() }
} }
step("Assert tokens were grouped on 'Organize tokens' screen") { step("Assert tokens were grouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsDisplayed() }
} }
step("Click 'Apply' button") { step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() } onOrganizeTokensScreen { applyButton.clickWithAssertion() }
} }
step("Assert tokens were grouped on 'Main screen'") { step("Assert tokens were grouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsDisplayed() } onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsDisplayed() }
} }
step("Open 'Organize tokens' screen") { step("Open 'Organize tokens' screen") {
openOrganizeTokensScreen() openOrganizeTokensScreen()
@ -60,17 +64,20 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitle(tokenTitle).assertIsDisplayed() tokenWithTitle(tokenTitle).assertIsDisplayed()
} }
} }
step("Click 'Ungroup' button") { step("Open organize menu") {
onOrganizeTokensScreen { ungroupButton.clickWithAssertion() } onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'Group' checkbox again to ungroup") {
onOrganizeTokensScreen { groupButton.clickWithAssertion() }
} }
step("Assert tokens were ungrouped on 'Organize tokens' screen") { step("Assert tokens were ungrouped on 'Organize tokens' screen") {
onOrganizeTokensScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } onOrganizeTokensScreen { tokenNetworkGroupTitle(networkTitleOrganize).assertIsNotDisplayed() }
} }
step("Click 'Apply' button") { step("Click 'Apply' button") {
onOrganizeTokensScreen { applyButton.clickWithAssertion() } onOrganizeTokensScreen { applyButton.clickWithAssertion() }
} }
step("Assert tokens were ungrouped on 'Main screen'") { step("Assert tokens were ungrouped on 'Main screen'") {
onMainScreen { tokenNetworkGroupTitle(tokenNetwork).assertIsNotDisplayed() } onMainScreen { tokenNetworkGroupTitle(networkTitleMain).assertIsNotDisplayed() }
} }
} }
} }
@ -185,6 +192,9 @@ class OrganizeTokensTest : BaseTestCase() {
tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed() tokenWithTitleAndPosition(polExMaticTitle, 3).assertIsDisplayed()
} }
} }
step("Open organize menu") {
onOrganizeTokensScreen { organizeMenuButton.clickWithAssertion() }
}
step("Click 'By Balance' button") { step("Click 'By Balance' button") {
onOrganizeTokensScreen { onOrganizeTokensScreen {
sortByBalanceButton.clickWithAssertion() sortByBalanceButton.clickWithAssertion()

View file

@ -35,11 +35,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(cardType) openMainScreen(cardType)
} }
step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") { step("Check 'Main' screen for '${cardType.name}' $cardBlockchain card") {
checkSingleCurrencyMainScreen( checkSingleCurrencyMainScreen(cardTitle = cardType.name)
cardBlockchain = cardBlockchain,
cardTitle = cardType.name,
withTransactions = true
)
} }
} }
} }
@ -57,7 +53,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType, isTwinsCard = true) openMainScreen(mockContent = cardType, isTwinsCard = true)
} }
step("Check 'Main' screen for '$cardName' $cardBlockchain card") { step("Check 'Main' screen for '$cardName' $cardBlockchain card") {
checkSingleCurrencyMainScreen(cardBlockchain = cardBlockchain, cardTitle = cardName) checkSingleCurrencyMainScreen(cardTitle = cardName)
} }
} }
} }
@ -66,7 +62,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with Secp256k1 curve") @DisplayName("Scan: Card with Secp256k1 curve")
@Test @Test
fun secpk1CurveCardScanTest() { fun secpk1CurveCardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Secpk1CurveMockContent val cardType: MockContent = Secpk1CurveMockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "card with Secp256k1 curve" val card = "card with Secp256k1 curve"
@ -75,12 +70,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on $card") { step("Open 'Main Screen' on $card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for $card curve with devices count = '$devicesCount'") { step("Check 'Main' screen for $card curve") {
checkMultiCurrencyMainScreen( checkMultiCurrencyMainScreen(cardTitle = cardName)
devicesCount = devicesCount,
cardTitle = cardName,
withWalletImage = false
)
} }
} }
} }
@ -99,11 +90,7 @@ class ScanCardTest : BaseTestCase() {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") { step("Check 'Main' screen for $card with blockchain: '$cardBlockchain'") {
checkSingleCurrencyMainScreen( checkSingleCurrencyMainScreen(cardTitle = cardName)
cardBlockchain = cardBlockchain,
cardTitle = cardName,
withWalletImage = false
)
} }
} }
} }
@ -112,7 +99,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Shiba' card") @DisplayName("Scan: 'Shiba' card")
@Test @Test
fun shibaCardScanTest() { fun shibaCardScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = ShibaMockContent val cardType: MockContent = ShibaMockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "Shiba" val card = "Shiba"
@ -121,8 +107,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") { step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -131,7 +117,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Ring'") @DisplayName("Scan: 'Ring'")
@Test @Test
fun ringScanTest() { fun ringScanTest() {
val devicesCount = "3 devices"
val cardType: ProductType = ProductType.Ring val cardType: ProductType = ProductType.Ring
val cardName = "Wallet" val cardName = "Wallet"
val ring = "Ring" val ring = "Ring"
@ -140,8 +125,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$ring'") { step("Open 'Main Screen' on '$ring'") {
openMainScreen(productType = cardType) openMainScreen(productType = cardType)
} }
step("Check 'Main' screen for '$ring' with devices count = '$devicesCount'") { step("Check 'Main' screen for '$ring'") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -150,7 +135,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet' card") @DisplayName("Scan: 'Wallet' card")
@Test @Test
fun walletCardScanTest() { fun walletCardScanTest() {
val devicesCount = "1 device"
val cardType: ProductType = ProductType.Wallet val cardType: ProductType = ProductType.Wallet
val cardName = "Wallet" val cardName = "Wallet"
@ -158,8 +142,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$cardName' card") { step("Open 'Main Screen' on '$cardName' card") {
openMainScreen(productType = cardType) openMainScreen(productType = cardType)
} }
step("Check 'Main' screen for '$cardName' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$cardName' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -168,7 +152,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: 'Wallet 2' card") @DisplayName("Scan: 'Wallet 2' card")
@Test @Test
fun wallet2ScanTest() { fun wallet2ScanTest() {
val devicesCount = "2 devices"
val cardType: MockContent = Wallet2MockContent val cardType: MockContent = Wallet2MockContent
val cardName = "Wallet" val cardName = "Wallet"
val card = "Wallet 2" val card = "Wallet 2"
@ -177,8 +160,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card' card") { step("Open 'Main Screen' on '$card' card") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' card with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card' card") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }
@ -187,7 +170,6 @@ class ScanCardTest : BaseTestCase() {
@DisplayName("Scan: Card with 4.12 firmware") @DisplayName("Scan: Card with 4.12 firmware")
@Test @Test
fun firmware412CardScanTest() { fun firmware412CardScanTest() {
val devicesCount = "1 device"
val cardType: MockContent = Firmware412MockContent val cardType: MockContent = Firmware412MockContent
val cardName = "Tangem card" val cardName = "Tangem card"
val card = "card with 4.12 firmware" val card = "card with 4.12 firmware"
@ -196,8 +178,8 @@ class ScanCardTest : BaseTestCase() {
step("Open 'Main Screen' on '$card'") { step("Open 'Main Screen' on '$card'") {
openMainScreen(mockContent = cardType) openMainScreen(mockContent = cardType)
} }
step("Check 'Main' screen for '$card' with devices count = '$devicesCount'") { step("Check 'Main' screen for '$card'") {
checkMultiCurrencyMainScreen(devicesCount, cardName) checkMultiCurrencyMainScreen(cardName)
} }
} }
} }

View file

@ -0,0 +1,68 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.domain.models.scan.ProductType
import com.tangem.scenarios.openDeviceSettingsScreen
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.scanCardInDeviceSettings
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class SecurityModeTest : BaseTestCase() {
@AllureId("2267")
@DisplayName("Security Mode: available for Twin cards")
@Test
fun securityModeOpensForTwinsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen(productType = ProductType.Twins, isTwinsCard = true)
}
step("Open 'Device settings' screen") {
openDeviceSettingsScreen()
}
step("Scan card in 'Device settings'") {
scanCardInDeviceSettings()
}
step("Assert 'Security mode' row is enabled") {
onDeviceSettingsScreen { securityModeRow.assertIsEnabled() }
}
step("Click on 'Security mode' button") {
onDeviceSettingsScreen { securityModeRow.performClick() }
}
onSecurityModeScreen {
step("Assert 'Long tap' option is displayed") {
longTapOptionDescription.assertIsDisplayed()
}
step("Assert 'Save changes' button is displayed") {
saveChangesButton.assertIsDisplayed()
}
}
}
@AllureId("9831")
@DisplayName("Security Mode: unavailable for single-capability cards")
@Test
fun securityModeRowDisabledForOtherCardsTest() =
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Open 'Device settings' screen") {
openDeviceSettingsScreen()
}
step("Scan card in 'Device settings'") {
scanCardInDeviceSettings()
}
step("Assert 'Security mode' row title is displayed") {
onDeviceSettingsScreen { securityModeRowTitle.assertIsDisplayed() }
}
step("Assert 'Security mode' row is disabled") {
onDeviceSettingsScreen { securityModeRow.assertIsNotEnabled() }
}
}
}

View file

@ -56,20 +56,14 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { stakingBlock.assertIsDisplayed() } onTokenDetailsScreen { stakingBlock.assertIsDisplayed() }
} }
step("Assert 'Staking title' is displayed") { step("Assert 'Staking title' is displayed") {
onTokenDetailsScreen { stakingTitle.assertIsDisplayed() } onTokenDetailsScreen { stakingEnabledTitle.assertIsDisplayed() }
} }
step("Assert 'Staking fiat amount' is displayed") { step("Assert 'Staking fiat amount' is displayed") {
onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() } onTokenDetailsScreen { stakingFiatAmount.assertIsDisplayed() }
} }
step("Assert 'Staking dot' is displayed") {
onTokenDetailsScreen { stakingDot.assertIsDisplayed() }
}
step("Assert 'Staking token amount' is displayed") { step("Assert 'Staking token amount' is displayed") {
onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() } onTokenDetailsScreen { stakingTokenAmount.assertIsDisplayed() }
} }
step("Assert 'Staking block chevron icon' is displayed") {
onTokenDetailsScreen { stakingChevronIcon.assertIsDisplayed() }
}
} }
} }
@ -139,6 +133,7 @@ class StakingTest : BaseTestCase() {
val scenarioName = "staking_eth_pol_balances_android" val scenarioName = "staking_eth_pol_balances_android"
val scenarioState = "Started" val scenarioState = "Started"
val stakingAmount = "1" val stakingAmount = "1"
val stakingApy = "2.84%"
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
@ -172,13 +167,10 @@ class StakingTest : BaseTestCase() {
onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() } onTokenDetailsScreen { availableStakingBlock.assertIsDisplayed() }
} }
step("Assert 'Available staking block' title is displayed") { step("Assert 'Available staking block' title is displayed") {
onTokenDetailsScreen { availableStakingBlockTitle.assertIsDisplayed() } onTokenDetailsScreen { stakingTitle.assertIsDisplayed() }
} }
step("Assert 'Available staking block' text is displayed") { step("Assert 'Available staking block' text is displayed") {
onTokenDetailsScreen { availableStakingBlockText.assertIsDisplayed() } onTokenDetailsScreen { availableStakingBlockText(stakingApy).assertIsDisplayed() }
}
step("Assert 'Available staking block' currency icon is displayed") {
onTokenDetailsScreen { availableStakingBlockCurrencyIcon.assertIsDisplayed() }
} }
step("Click on 'Stake' button") { step("Click on 'Stake' button") {
onTokenDetailsScreen { stakeButton.clickWithAssertion() } onTokenDetailsScreen { stakeButton.clickWithAssertion() }

View file

@ -0,0 +1,59 @@
package com.tangem.tests
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.openMainScreen
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class WalletRenameTest : BaseTestCase() {
@AllureId("2264")
@DisplayName("Wallet details: rename wallet")
@Test
fun renameWalletTest() =
setupHooks().run {
val newWalletName = "Tangem QA"
step("Open 'Main Screen'") {
openMainScreen()
}
step("Open wallet details") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings' screen") {
onDetailsScreen { walletNameButton.clickWithAssertion() }
}
step("Click on 'Rename' button") {
onWalletSettingsScreen {
scrollToRenameButton()
renameWalletButton.clickWithAssertion()
}
}
step("Enter new wallet name '$newWalletName'") {
onDialog { inputField.performTextReplacement(newWalletName) }
}
step("Click on 'OK' button") {
onDialog { okButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Wallet settings' screen") {
onWalletSettingsScreen { walletNameValue(newWalletName).assertIsDisplayed() }
}
step("Click on 'Back' button") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Details' screen") {
onDetailsScreen { walletNameValue(newWalletName).assertIsDisplayed() }
}
step("Click on 'Back' button") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert new wallet name '$newWalletName' is displayed on 'Main' screen") {
onMainScreen { walletNameText.assertTextContains(newWalletName) }
}
}
}

View file

@ -1,14 +1,10 @@
package com.tangem.tests package com.tangem.tests
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent import com.tangem.tap.domain.sdk.mocks.content.DevWalletMockContent
import com.tangem.tap.domain.sdk.mocks.content.Wallet2WithSeedPhraseMockContent
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test import org.junit.Test

View file

@ -3,6 +3,7 @@ package com.tangem.tests.accounts
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO import com.tangem.common.constants.TestConstants.REFERRAL_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -10,7 +11,9 @@ import com.tangem.core.ui.R
import com.tangem.scenarios.* import com.tangem.scenarios.*
import com.tangem.screens.accounts.onAccountDetailsScreen import com.tangem.screens.accounts.onAccountDetailsScreen
import com.tangem.screens.accounts.onArchivedAccountsScreen import com.tangem.screens.accounts.onArchivedAccountsScreen
import com.tangem.screens.onDetailsScreen
import com.tangem.screens.onDialog import com.tangem.screens.onDialog
import com.tangem.screens.onMainScreen
import com.tangem.screens.onWalletSettingsScreen import com.tangem.screens.onWalletSettingsScreen
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString import io.github.kakaocup.kakao.common.utilities.getResourceString
@ -164,8 +167,8 @@ class AccountArchivationsTest : BaseTestCase() {
@Test @Test
@AllureId("5976") @AllureId("5976")
@DisplayName("Accounts: restore an archived account") @DisplayName("Accounts: restore a simple archived account")
fun restoreArchivedAccountTest() { fun restoreSimpleArchivedAccountTest() {
val archivedAccountName = "Account 3" val archivedAccountName = "Account 3"
val userAccountsInitialState = "TwoAccountsWithArchivedAccounts" val userAccountsInitialState = "TwoAccountsWithArchivedAccounts"
val userAccountsAfterArchivationState = "ReadyToRestore" val userAccountsAfterArchivationState = "ReadyToRestore"
@ -202,6 +205,108 @@ class AccountArchivationsTest : BaseTestCase() {
} }
} }
@Test
@AllureId("5980")
@DisplayName("Accounts: restore archived account with custom token transfer")
fun restoreArchivedAccountWithCustomTokensTest() {
val mainAccountName = "Main account"
val archivedAccountName = "Account 2"
val customTokenName = "Ethereum"
val expectedArchivedTokensInfo = "1 token"
val userAccountsInitialState = "OneAccountWithArchivedCustomToken"
val userAccountsReadyToRestoreState = "ReadyToRestoreCustomToken"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsInitialState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Open 'Archived accounts' screen") { openArchivedAccountsScreen() }
step("Verify archived account '$archivedAccountName' shows '$expectedArchivedTokensInfo'") {
onArchivedAccountsScreen {
val row = findArchivedAccountItemByName(archivedAccountName)
row.container.assertIsDisplayed()
row.subtitle.assertTextContains(expectedArchivedTokensInfo, substring = true)
}
}
step("Switch WireMock to '$userAccountsReadyToRestoreState'") {
setWireMockScenarioState(userTokensScenario, userAccountsReadyToRestoreState)
}
step("Click restore button for '$archivedAccountName'") {
onArchivedAccountsScreen {
findArchivedAccountItemByName(archivedAccountName)
.restoreButton.clickWithAssertion()
}
}
step("Assert custom token migration dialog is displayed") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Assert dialog text mentions main account '$mainAccountName'") {
onDialog { text.assertTextContains(mainAccountName, substring = true) }
}
step("Assert dialog text mentions restoring account '$archivedAccountName'") {
onDialog { text.assertTextContains(archivedAccountName, substring = true) }
}
step("Confirm migration in dialog") {
onDialog { gotItButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is in active accounts list") {
onWalletSettingsScreen { accountItem(archivedAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert main account '$mainAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(mainAccountName).assertIsDisplayed() }
}
step("Assert restored account '$archivedAccountName' is visible on main screen") {
onMainScreen { findAccountSectionByName(archivedAccountName).assertIsDisplayed() }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen { assertTokenDoesNotExist(customTokenName) }
}
step("Expand main account '$mainAccountName'") {
onMainScreen { findAccountSectionByName(mainAccountName).clickWithAssertion() }
}
step("Assert '$customTokenName' is NOT displayed under main account") {
onMainScreen {
assertTokenDoesNotExist(customTokenName)
}
}
step("Expand restored account '$archivedAccountName' and assert '$customTokenName' is displayed") {
onMainScreen {
findAccountSectionByName(archivedAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { findTokenInAnyAccountByName(customTokenName).assertIsDisplayed() }
},
)
}
}
}
}
@Test @Test
@AllureId("7962") @AllureId("7962")
@DisplayName("Accounts: restore archived account error") @DisplayName("Accounts: restore archived account error")
@ -250,4 +355,5 @@ class AccountArchivationsTest : BaseTestCase() {
} }
} }
} }
} }

View file

@ -0,0 +1,492 @@
package com.tangem.tests.accounts
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.extensions.clickAndWaitFor
import com.tangem.common.extensions.clickOnSystemButton
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.DerivationPathHelper
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setClipboardText
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountInfoEditorScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertTrue
import org.junit.Test
@HiltAndroidTest
class AccountCreationTest : BaseTestCase() {
private val userTokensScenario = "user_tokens_api"
@Test
@AllureId("5504")
@DisplayName("Accounts: account creation network error handling")
fun accountCreationErrorTest() {
val accountName = "Account 2"
val userAccountsGetErrorState = "AccountsGetError"
val userAccountsPutErrorState = "AccountsPutError"
val userAccountsBeforeCreationState = "AccountReadyToCreate"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, userAccountsGetErrorState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Click 'Add account' button (GET accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert error dialog details") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(com.tangem.core.ui.R.string.account_generic_error_dialog_message),
)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Unblock GET accounts, block PUT accounts") {
setWireMockScenarioState(userTokensScenario, userAccountsPutErrorState)
}
step("Click 'Add account' button again (PUT accounts is blocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onDialog { dialogContainer.assertIsDisplayed() }
},
)
}
}
step("Assert still on account creation screen") {
assertErrorDialog(
expectedTitle = getResourceString(R.string.common_something_went_wrong),
expectedMessage = getResourceString(R.string.account_generic_error_dialog_message),
)
}
step("Unblock both 'accounts' requests") {
setWireMockScenarioState(userTokensScenario, userAccountsBeforeCreationState)
}
step("Dismiss error dialog") { dismissErrorDialog() }
step("Assert still on account creation screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' button again (both requests unblocked)") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' title is displayed") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$accountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(accountName).assertIsDisplayed() }
}
}
}
@Test
@AllureId("5507")
@DisplayName("Accounts: name field verifications")
fun accountsCreationNameFieldValidationTest() {
val accountName = "TestAccount12"
val longName = "A".repeat(21)
val emptyPlaceholderValue = "New account"
val editedName = "Edited"
val context = device.context
val pasteButtonName = "Paste"
setupHooks().run {
step("Set clipboard text '$longName'") {
setClipboardText(context,longName)
}
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert 'Edit account details' dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Assert name input is stable (keyboard doesn't flicker)") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Clear the 'Edit name' field") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button becomes inactive when field is empty") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Paste name from clipboard: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(accountName)
}
}
step("Assert pasted text is displayed in 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Assert 'Add account' button is enabled") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsEnabled()
}
}
step("Edit the entered name (clear and retype)") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(editedName)
}
}
step("Assert edited name in 'Account name' field is displayed") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(editedName)
}
}
step("Delete all text and leave 'Account name' field empty") {
onAccountInfoEditorScreen {
accountNameField.performTextClearance()
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Type name with more than 20 symbols") {
onAccountInfoEditorScreen {
accountNameField.performTextReplacement(longName)
}
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
step("Clear text field") {
onAccountInfoEditorScreen { accountNameField.performTextClearance() }
}
step("Paste text longer than 20 characters to 'Account name' field") {
onAccountInfoEditorScreen {
accountNameField.performTouchInput { longClick(durationMillis = 2_000L) }
}
}
step("Click on system 'Paste' button to paste clipboard text") {
clickOnSystemButton(pasteButtonName)
}
step("Assert text over 20 symbols was not pasted and placeholder remains empty") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(emptyPlaceholderValue, substring = true)
}
}
step("Assert 'Add account' button is inactive") {
onAccountInfoEditorScreen {
saveAccountButton.assertIsNotEnabled()
}
}
}
}
@Test
@AllureId("5505")
@DisplayName(
"Accounts: check unsaved changes notification " +
"after attempt to close edited account creation form"
)
fun accountsCreationUnsavedChangesForNameFieldNotificationTest() {
val accountName = "Hikarik Test"
setupHooks().run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open 'Wallet settings' screen") { openWalletSettingsScreen() }
step("Click on 'Add account' button") {
onWalletSettingsScreen { addAccountButton.clickWithAssertion() }
}
step("Assert edit account details dialog screen appears") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Enter account name manually: '$accountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen {
crossButton.clickWithAssertion()
}
}
step("Verify 'Unsaved changes' screen parts") {
checkUnsavedChangesCreationModal()
}
step("Tap 'Keep Editing' button to stay on screen") {
onDialog { keepEditButton.clickWithAssertion() }
}
step("Assert app still on 'Create account' screen") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Assert previously entered data is preserved") {
onAccountInfoEditorScreen {
accountNameField.assertTextContains(accountName)
}
}
step("Tap 'Cross' button to attempt closing the screen") {
onAccountInfoEditorScreen { crossButton.clickWithAssertion() }
}
step("Assert 'Unsaved changes' alert is displayed again") {
onDialog { dialogContainer.assertIsDisplayed() }
}
step("Tap 'Discard' button to discard and close") {
onDialog { discardButton.clickWithAssertion() }
}
step("Assert 'Create account' screen is closed and 'Wallet settings' displayed again") {
onWalletSettingsScreen {
screenContainer.assertIsDisplayed()
}
}
step("Verify no new account has appeared in the list") {
onWalletSettingsScreen {
accountItem(accountName).assertDoesNotExist()
}
}
}
}
@Test
@AllureId("5502")
@DisplayName("Accounts: account creation, accounts mode and per-account token derivation")
fun accountCreationAndDerivationTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateDerivation"
val accountIndex = "1"
val btcTokenName = "Bitcoin"
val ethTokenName = "Ethereum"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Assert account creation screen with derivation hint is displayed") {
onAccountInfoEditorScreen { screenContainer.assertIsDisplayed() }
}
step("Click 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' screen") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new account '$createdAccountName' appears (last) in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert accounts mode is on main: account '$createdAccountName' section is visible") {
onMainScreen { findAccountSectionByName(createdAccountName).assertIsDisplayed() }
}
step("Assert per-account token derivation paths from the domain account model") {
val account = awaitCryptoPortfolioAccount(derivationIndex = accountIndex.toInt())
val btcPaths = account.derivationPathsForToken(btcTokenName)
assertTrue(
"Expected a $btcTokenName derivation with 3rd node = $accountIndex' (account index). Paths: $btcPaths",
btcPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 3) == "$accountIndex'" },
)
val ethPaths = account.derivationPathsForToken(ethTokenName)
assertTrue(
"Expected an $ethTokenName derivation with 5th node = $accountIndex (account index). Paths: $ethPaths",
ethPaths.any { DerivationPathHelper.nodeAt(it, index1Based = 5) == accountIndex },
)
}
}
}
@Test
@AllureId("8746")
@DisplayName("Accounts: empty account placeholder and 'Add tokens' entry to manage tokens")
fun emptyAccountPlaceholderTest() {
val createdAccountName = "Account 2"
val accountReadyState = "AccountReadyToCreateEmpty"
setupHooks(
additionalBeforeSection = {
setWireMockScenarioState(userTokensScenario, accountReadyState)
},
additionalAfterSection = {
resetWireMockScenarioState(userTokensScenario)
},
).run {
step("Open 'Main Screen'") { openMainScreen() }
step("Synchronize addresses") { synchronizeAddresses() }
step("Open wallet settings") { openWalletSettingsScreen() }
step("Start account creation") { startAccountCreation() }
step("Enter account name: '$createdAccountName'") {
onAccountInfoEditorScreen {
accountNameField.performClick()
accountNameField.performTextInput(createdAccountName)
}
}
step("Click on 'Add account' and wait for 'Manage Tokens'") {
onAccountInfoEditorScreen {
saveAccountButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Close 'Manage Tokens' without adding any token") {
onManageTokensScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Assert 'Wallet settings' screen is displayed") {
onWalletSettingsScreen { addAccountButton.assertIsDisplayed() }
}
step("Assert new empty account '$createdAccountName' appears in accounts list") {
onWalletSettingsScreen { accountItem(createdAccountName).assertIsDisplayed() }
}
step("Navigate back to wallet details") {
onWalletSettingsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Navigate back to main screen") {
onDetailsScreen { topAppBarBackButton.clickWithAssertion() }
}
step("Expand empty account '$createdAccountName' section") {
onMainScreen {
scrollToAccountSection(createdAccountName)
findAccountSectionByName(createdAccountName).clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
},
)
}
}
step("Assert empty tokens placeholder is displayed") {
onMainScreen { emptyAccountTokensPlaceholder.assertIsDisplayed() }
}
step("Assert 'Add tokens' button is displayed under the placeholder") {
onMainScreen { emptyAccountAddTokensButton.assertIsDisplayed() }
}
step("Click on 'Add tokens' button") {
onMainScreen {
emptyAccountAddTokensButton.clickAndWaitFor(
rule = composeTestRule,
expectedCondition = {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
},
)
}
}
step("Assert 'Manage Tokens' screen is opened for the account") {
onManageTokensScreen { topAppBarTitle.assertIsDisplayed() }
}
}
}
}

View file

@ -383,11 +383,17 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Open 'Main Screen' on '$cardName' card") { step("Open 'Main Screen' on '$cardName' card") {
openMainScreen(mockContent = cardType, isTwinsCard = true) openMainScreen(mockContent = cardType, isTwinsCard = true)
} }
step("Assert 'Buy' button is displayed") { step("Assert 'Add funds' button is displayed") {
onMainScreen { buyButton.assertIsDisplayed() } onMainScreen { addFundsButton.assertIsDisplayed() }
} }
step("Click on 'Buy' button") { step("Click on 'Add funds' button") {
onMainScreen { buyButton.performClick() } onMainScreen { addFundsButton.performClick() }
}
step("Click on '$tokenTitle'") {
onAddFundsBottomSheet { userTokenWithTitle(tokenTitle).clickWithAssertion() }
}
step("Click on 'Buy' button in bottom sheet") {
onGetTokenBottomSheet { buyButton.performClick() }
} }
step("Click on 'Confirm' button in 'Dialog'") { step("Click on 'Confirm' button in 'Dialog'") {
waitForIdle() waitForIdle()
@ -426,10 +432,10 @@ class MainScreenActionButtonsTest : BaseTestCase() {
onMainScreen { addFundsButton.performClick() } onMainScreen { addFundsButton.performClick() }
} }
step("Assert 'Choose token' screen title is displayed") { step("Assert 'Choose token' screen title is displayed") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } onChooseTokenBottomSheet { title.assertIsDisplayed() }
} }
step("Assert token with title: '$tokenTitle' is displayed") { step("Assert token with title: '$tokenTitle' is displayed") {
onChooseTokenScreen { tokenWithTitle(tokenTitle).assertIsDisplayed() } onChooseTokenBottomSheet { tokenWithTitle(tokenTitle).assertIsDisplayed() }
} }
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -449,14 +455,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() } onMainScreen { transferButton.assertIsDisplayed() }
} }
step("Click on 'Sell' button") { step("Click on 'Transfer' button") {
onMainScreen { sellButton.performClick() } onMainScreen { transferButton.performClick() }
} }
step("Assert 'Sell' token screen title is displayed") { step("Assert 'Choose token' title is displayed") {
onSellScreen { title.assertIsDisplayed() } onChooseTokenBottomSheet { title.assertIsDisplayed() }
} }
} }
} }
@ -485,7 +491,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
onMainScreen { addFundsButton.performClick() } onMainScreen { addFundsButton.performClick() }
} }
step("Assert 'Choose token' screen opens (Add funds is always available)") { step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } onChooseTokenBottomSheet { title.assertIsDisplayed() }
} }
step("Press 'Back' to return to main screen") { step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -498,22 +504,21 @@ class MainScreenActionButtonsTest : BaseTestCase() {
onMainScreen { swapButton.performClick() } onMainScreen { swapButton.performClick() }
} }
step("Check 'Action is unavailable' dialog") { step("Check 'Action is unavailable' dialog") {
checkActionIsUnavailableDialog() flakySafely(WAIT_UNTIL_TIMEOUT) {
checkActionIsUnavailableDialog()
}
} }
step("Click on 'Ok' button") { step("Click on 'Ok' button") {
onDialog { okButton.performClick() } onDialog { okButton.performClick() }
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() } onMainScreen { transferButton.assertIsDisplayed() }
} }
step("Click on 'Sell' button") { step("Click on 'Transfer' button") {
onMainScreen { sellButton.performClick() } onMainScreen { transferButton.performClick() }
} }
step("Check 'Action is unavailable' dialog") { step("Assert 'Choose token' title is displayed") {
checkActionIsUnavailableDialog() onChooseTokenBottomSheet { title.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
} }
} }
} }
@ -543,7 +548,7 @@ class MainScreenActionButtonsTest : BaseTestCase() {
onMainScreen { addFundsButton.performClick() } onMainScreen { addFundsButton.performClick() }
} }
step("Assert 'Choose token' screen opens (Add funds is always available)") { step("Assert 'Choose token' screen opens (Add funds is always available)") {
onChooseTokenScreen { topAppBarTitle.assertIsDisplayed() } onChooseTokenBottomSheet { title.assertIsDisplayed() }
} }
step("Press 'Back' to return to main screen") { step("Press 'Back' to return to main screen") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -561,17 +566,14 @@ class MainScreenActionButtonsTest : BaseTestCase() {
step("Click on 'Ok' button") { step("Click on 'Ok' button") {
onDialog { okButton.performClick() } onDialog { okButton.performClick() }
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onMainScreen { sellButton.assertIsDisplayed() } onMainScreen { transferButton.assertIsDisplayed() }
} }
step("Click on 'Sell' button") { step("Click on 'Transfer' button") {
onMainScreen { sellButton.performClick() } onMainScreen { transferButton.performClick() }
} }
step("Check 'Action is unavailable' dialog") { step("Assert 'Choose token' title is displayed") {
checkActionIsUnavailableDialog() onChooseTokenBottomSheet { title.assertIsDisplayed() }
}
step("Click on 'Ok' button") {
onDialog { okButton.performClick() }
} }
} }
} }

View file

@ -2,16 +2,21 @@ package com.tangem.tests.actionButtons
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertIsDimmed
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkQrCodeBottomSheetScenario import com.tangem.scenarios.checkQrCodeBottomSheetScenario
import com.tangem.scenarios.goToQrCodeBottomSheet import com.tangem.scenarios.goToQrCodeBottomSheet
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.openSendFromTokenDetails
import com.tangem.scenarios.synchronizeAddresses import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onMainScreen import com.tangem.screens.onMainScreen
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSwapStoriesScreen import com.tangem.screens.onSwapStoriesScreen
import com.tangem.screens.onSwapTokenScreen import com.tangem.screens.onSwapTokenScreen
import com.tangem.screens.onTokenDetailsScreen import com.tangem.screens.onTokenDetailsScreen
import com.tangem.screens.onTransferBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
@ -37,20 +42,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Assert 'Receive' button is displayed") { step("Assert 'Add funds' button is displayed") {
onTokenDetailsScreen { receiveButton().assertIsDisplayed() } onTokenDetailsScreen { addFundsButton.assertIsDisplayed() }
}
step("Assert 'Buy' button is displayed") {
onTokenDetailsScreen { buyButton().assertIsDisplayed() }
}
step("Assert 'Send' button is displayed") {
onTokenDetailsScreen { sendButton().assertIsDisplayed() }
} }
step("Assert 'Swap' button is displayed") { step("Assert 'Swap' button is displayed") {
onTokenDetailsScreen { swapButton().assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
} }
step("Assert 'Sell' button is displayed") { step("Assert 'Transfer' button is displayed") {
onTokenDetailsScreen { sellButton().assertIsDisplayed() } onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Click on 'Add funds' button") {
onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Assert 'Buy' button in bottom sheet is displayed") {
onAddFundsBottomSheet { buyButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onAddFundsBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Receive' button in bottom sheet is displayed") {
onAddFundsBottomSheet { receiveButton.assertIsDisplayed() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is displayed") {
onTransferBottomSheet { sendButton.assertIsDisplayed() }
}
step("Assert 'Swap' button in bottom sheet is displayed") {
onTransferBottomSheet { swapButton.assertIsDisplayed() }
}
step("Assert 'Sell' button in bottom sheet is displayed") {
onTransferBottomSheet { sellButton.assertIsDisplayed() }
} }
} }
} }
@ -72,20 +98,41 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Assert 'Receive' button is not dimmed") { step("Assert 'Add funds' button is enabled") {
onTokenDetailsScreen { receiveButton().assertIsDimmed(false) } onTokenDetailsScreen { addFundsButton.assertIsEnabled() }
} }
step("Assert 'Buy' button is not dimmed") { step("Assert 'Swap' button is disabled") {
onTokenDetailsScreen { buyButton().assertIsDimmed(false) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
step("Assert 'Send' button is not dimmed") { step("Assert 'Transfer' button is enabled") {
onTokenDetailsScreen { sendButton().assertIsDimmed(false) } onTokenDetailsScreen { transferButton.assertIsEnabled() }
} }
step("Assert 'Swap' button is dimmed") { step("Click on 'Add funds' button") {
onTokenDetailsScreen { swapButton().assertIsDimmed() } onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
} }
step("Assert 'Sell' button is dimmed") { step("Assert 'Buy' button in bottom sheet is enabled") {
onTokenDetailsScreen { sellButton().assertIsDimmed() } onAddFundsBottomSheet { buyButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onAddFundsBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Receive' button in bottom sheet is enabled") {
onAddFundsBottomSheet { receiveButton.assertIsEnabled() }
}
step("Click on 'Close' button in bottom sheet") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Assert 'Send' button in bottom sheet is enabled") {
onTransferBottomSheet { sendButton.assertIsEnabled() }
}
step("Assert 'Swap' button in bottom sheet is disabled") {
onTransferBottomSheet { swapButton.assertIsNotEnabled() }
}
step("Assert 'Sell' button in bottom sheet is disabled") {
onTransferBottomSheet { sellButton.assertIsNotEnabled() }
} }
} }
} }
@ -109,7 +156,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -140,8 +187,11 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenTitle).performClick() }
} }
step("Click on 'Receive' button") { step("Click on 'Add funds' button") {
onTokenDetailsScreen { receiveButton().performClick() } onTokenDetailsScreen { addFundsButton.clickWithAssertion() }
}
step("Click on 'Receive' button in bottom sheet") {
onAddFundsBottomSheet { receiveButton.clickWithAssertion() }
} }
step("Go to QR code bottom sheet") { step("Go to QR code bottom sheet") {
flakySafely(WAIT_UNTIL_TIMEOUT) { flakySafely(WAIT_UNTIL_TIMEOUT) {
@ -153,4 +203,58 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() {
} }
} }
} }
@AllureId("591")
@DisplayName("Action buttons (token details screen): send available for funded token, unavailable for empty token")
@Test
fun checkSendAvailabilityForFundedAndEmptyTokenTest() {
val emptyTokenTitle = "Polygon"
val fundedTokenTitle = "Ethereum"
val polygonBalanceScenarioName = "polygon_coin_balance"
val polygonBalanceScenarioState = "ZeroBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(polygonBalanceScenarioName)
}
).run {
step("Set WireMock scenario: '$polygonBalanceScenarioName' to state: '$polygonBalanceScenarioState'") {
setWireMockScenarioState(polygonBalanceScenarioName, polygonBalanceScenarioState)
}
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$emptyTokenTitle'") {
waitForIdle()
onMainScreen { tokenWithTitleAndAddress(emptyTokenTitle).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Assert 'Transfer' button is not displayed for the empty token") {
onTokenDetailsScreen { transferButton.assertIsNotDisplayed() }
}
step("Go back to 'Main Screen'") {
device.uiDevice.pressBack()
}
step("Assert 'Main Screen' is displayed") {
onMainScreen { screenContainer.assertIsDisplayed() }
}
step("Click on token with name: '$fundedTokenTitle'") {
onMainScreen { tokenWithTitleAndAddress(fundedTokenTitle).clickWithAssertion() }
}
step("Assert 'Transfer' button is displayed for the funded token") {
onTokenDetailsScreen { transferButton.assertIsDisplayed() }
}
step("Open the send flow from token details") {
openSendFromTokenDetails()
}
step("Assert 'Send' screen is displayed") {
onSendScreen { amountInputTextField.assertIsDisplayed() }
}
}
}
} }

View file

@ -0,0 +1,86 @@
package com.tangem.tests.addFunds
import com.tangem.common.BaseTestCase
import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onAddTokenBottomSheet
import com.tangem.screens.onAddFundsBottomSheet
import com.tangem.screens.onBuyTokenDetailsScreen
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class BuyTest : BaseTestCase() {
@AllureId("587")
@DisplayName("Buy. Display tokens available for purchase")
@Test
fun buyDisplayTokensAvailableForPurchaseTest() {
val token = "Bitcoin"
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Add Funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on $token in Wallet list") {
onAddFundsBottomSheet { userTokenWithTitle(token).clickWithAssertion() }
}
step("Click on 'Buy' button") {
onAddFundsBottomSheet { buyTokenButton.clickWithAssertion() }
}
step("Verify 'Buy $token' title is displayed") {
onBuyTokenDetailsScreen {
topBarTitle.assertTextContainsSafe("Buy $token", substring = true)
}
}
}
}
@AllureId("590")
@DisplayName("Buy. Adding trending token to the main screen")
@Test
fun buyAddingTrendingTokenToMainScreenTest() {
val token = "Solana"
setupHooks().run {
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on 'Add Funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Click on $token in Trending list") {
onAddFundsBottomSheet { trendingTokenWithTitle(token).clickWithAssertion() }
}
step("Click on 'Add' button") {
onAddTokenBottomSheet { addButton.clickWithAssertion() }
}
step("Close 'Get token' screen") {
onAddFundsBottomSheet { closeButton.clickWithAssertion() }
}
step("Verify token $token exists on main screen") {
onMainScreen { assertTokenExists(token) }
}
step("Click on 'Add Funds' button") {
onMainScreen { addFundsButton.clickWithAssertion() }
}
step("Verify token $token in Wallet list") {
onAddFundsBottomSheet { userTokenWithTitle(token).assertIsDisplayed() }
}
}
}
}

View file

@ -1,42 +0,0 @@
package com.tangem.tests.balance
import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase
import com.tangem.scenarios.openMainScreen
import com.tangem.scenarios.synchronizeAddresses
import com.tangem.screens.onMainScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class TotalBalanceLongTapTest : BaseTestCase() {
@Test
@AllureId("3965")
@DisplayName("Total balance: check long tap on block without biometry")
fun whenBiometryIsOffTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Long tap on total balance block") {
onMainScreen {
totalBalanceContainer.performTouchInput {
longClick()
}
}
}
step("Assert 'Rename' button is displayed") {
onMainScreen { totalBalanceMenuRenameWallet.assertIsDisplayed() }
}
step("Assert 'Delete' button is not displayed") {
onMainScreen { totalBalanceMenuDeleteWallet.assertIsNotDisplayed() }
}
}
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.tests.balance
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TOTAL_BALANCE import com.tangem.common.constants.TestConstants.TOTAL_BALANCE
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.* import com.tangem.common.extensions.*
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -73,7 +74,7 @@ class TotalBalanceUpdateTest : BaseTestCase() {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }
} }
step("Open 'Markets screen'") { step("Open 'Markets screen'") {
onMainScreen { searchThroughMarketPlaceholder.performClick() } onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Click on $tokenTitle token") { step("Click on $tokenTitle token") {
@ -82,28 +83,25 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
} }
step("Click on 'Add to portfolio' button") { step("Click on 'Add' button in 'Markets' bottom sheet") {
onMarketsScreen { addToPortfolioButton.clickWithAssertion() } onMarketsScreen { addButton.clickWithAssertion() }
} }
step("Click on main network") { step("Click on 'Add' button in 'Add token' bottom sheet") {
onMarketsScreen { mainNetworkSuffix.performClick() } flakySafely(WAIT_UNTIL_TIMEOUT) {
onAddTokenBottomSheet { addButton.performClick() }
}
} }
step("Click on 'Add' button") { step("Press 'Back' button") {
onDialog { addButton.clickWithAssertion() }
}
step("Assert 'Continue' is not displayed") {
onDialog { addButton.assertIsNotDisplayed() }
}
step("Click on 'Later' button") {
onDialog { laterButton.clickWithAssertion() }
}
step("Go back to 'Markets: tokens list'") {
waitForIdle() waitForIdle()
onMarketsScreen { topBarBackButton.clickWithAssertion() } device.uiDevice.pressBack()
} }
step("Close 'Markets screen'") { step("Press 'Back' button") {
onSearchBar { searchField.assertIsDisplayed() } waitForIdle()
swipeMarketsBlock(SwipeDirection.DOWN) device.uiDevice.pressBack()
}
step("Press 'Back' button") {
waitForIdle()
device.uiDevice.pressBack()
} }
step("Assert $updatedBalance is displayed in total balance") { step("Assert $updatedBalance is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(updatedBalance) } onMainScreen { totalBalanceText.assertTextContains(updatedBalance) }
@ -201,8 +199,11 @@ class TotalBalanceUpdateTest : BaseTestCase() {
step("Assert 'Token details screen' open") { step("Assert 'Token details screen' open") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() } onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
} }
step("Click 'More' button") { step("Click on 'Back' button") {
onTokenDetailsTopBar { backButton.clickWithAssertion() } flakySafely(WAIT_UNTIL_TIMEOUT) {
onTokenDetailsTopBar { backButton.clickWithAssertion() }
onMainScreen { screenContainer.assertIsDisplayed() }
}
} }
step("Assert $TOTAL_BALANCE is displayed in total balance") { step("Assert $TOTAL_BALANCE is displayed in total balance") {
onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) } onMainScreen { totalBalanceText.assertTextContains(TOTAL_BALANCE) }

View file

@ -0,0 +1,251 @@
package com.tangem.tests.hotWallet
import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.CREATE_USER_WALLET_API_SCENARIO
import com.tangem.common.constants.TestConstants.MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
import com.tangem.common.constants.TestConstants.SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.SEED_PHRASE_HAPPY_PATH
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WALLET_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreenWithExistingHotWallet
import com.tangem.screens.*
import com.tangem.screens.accounts.onAccountDetailsScreen
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
import com.tangem.core.ui.R as CoreUiR
@HiltAndroidTest
class AssetsDiscoveryTest : BaseTestCase() {
private companion object {
const val DISCOVERY_TIMEOUT_MILLIS = 120_000L
const val SCENARIO_STATE_STARTED = "Started"
const val SCENARIO_STATE_EMPTY = "Empty"
const val SCENARIO_STATE_ALREADY_EXISTS = "AlreadyExists"
const val SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT = "AssetsDiscoveryRedirect"
const val SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH = "AssetsDiscoveryHappyPath"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES = "NonZeroEvmBalances"
const val SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW = "NonZeroEvmBalancesSlow"
val EXPECTED_DISCOVERED_TOKENS = listOf(
"Ethereum",
"Polygon",
"Tether",
)
val TOKENS_THAT_MUST_NOT_APPEAR = listOf(
"Solana",
"USDC",
)
val BACKEND_PRE_POPULATED_TOKENS = listOf(
"Bitcoin",
"Ethereum",
"Polygon",
)
}
@AllureId("9280")
@DisplayName("Hot wallet: new import — Discovery → Sync → Banner → Check here happy path")
@Test
fun newHotWalletImportHappyPathTest() {
val packageName = getTargetContext().packageName
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Wait for 'Wallet successfully imported' banner (discovery completes)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert expected discovered tokens are visible in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Tap 'Check here' (Manage tokens) on the banner") {
onMainScreen { walletImportedBannerCheckHereButton.clickWithAssertion() }
}
step("Assert 'Manage Tokens' screen is opened") {
onManageTokensScreen { searchField.assertIsDisplayed() }
}
step("Return to main screen") {
device.uiDevice.pressBack()
waitForIdle()
}
step("Assert banner is hidden after navigating into Manage Tokens") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Force-close and re-launch the app") {
restartApp(packageName)
}
step("Assert banner is NOT shown again after relaunch") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
step("Assert previously discovered tokens still appear in the assets list") {
onMainScreen {
EXPECTED_DISCOVERED_TOKENS.forEach { token ->
tokenRowWithTitle(token).assertIsDisplayed()
}
}
}
step("Assert zero-balance and spam tokens are NOT shown in the assets list") {
onMainScreen {
TOKENS_THAT_MUST_NOT_APPEAR.forEach { token ->
assertTokenDoesNotExist(token)
}
}
}
}
}
@AllureId("9284")
@DisplayName("Hot wallet: token added manually during Discovery — no duplicate created")
@Test
fun manualTokenAddDuringDiscoveryNoDuplicateTest() {
val tetherTitle = "Tether"
val ethereumNetworkTitle = "ETHEREUM"
val accountName = getResourceString(CoreUiR.string.account_main_account_title)
val expectedTokensCount = 2
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(PROVIDERS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_REDIRECT)
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_ASSETS_DISCOVERY_HAPPY_PATH)
setWireMockScenarioState(WALLET_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(
MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO,
state = SCENARIO_STATE_NON_ZERO_EVM_BALANCES_SLOW,
)
},
additionalAfterSection = {
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(WALLET_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import a new hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_HAPPY_PATH)
}
step("Assert 'Restoring' progress loader is shown (discovery is in flight)") {
onMainScreen { restoringProgressText.assertIsDisplayed() }
}
step("Open wallet details from top bar") {
onMainScreenTopBar { moreButton.clickWithAssertion() }
}
step("Open 'Wallet settings'") {
onDetailsScreen { walletNameButton.performClick() }
}
step("Open account: '$accountName'") {
onWalletSettingsScreen { accountItem(accountName).performClick() }
}
step("Open 'Manage Tokens' from account details") {
onAccountDetailsScreen { manageTokensButton.performClick() }
}
step("Search for '$tetherTitle' in Manage Tokens") {
onManageTokensScreen {
searchField.performClick()
searchField.performTextInput(tetherTitle)
}
device.uiDevice.pressBack()
waitForIdle()
}
step("Expand '$tetherTitle'") {
onManageTokensScreen { tokenItem(tetherTitle).clickWithAssertion() }
waitForIdle()
}
step("Enable the $ethereumNetworkTitle network") {
onManageTokensScreen { networkSwitch(ethereumNetworkTitle).clickWithAssertion() }
}
step("Save Manage Tokens changes") {
onManageTokensScreen { saveButton.clickWithAssertion() }
waitForIdle()
}
step("Navigate back to main screen") {
repeat(times = 3) {
device.uiDevice.pressBack()
waitForIdle()
}
}
step("Wait for 'Wallet successfully imported' banner (discovery completes after delay)") {
flakySafely(timeoutMs = DISCOVERY_TIMEOUT_MILLIS) {
onMainScreen { walletImportedBanner.assertIsDisplayed() }
}
}
step("Assert '$tetherTitle' is in the assets list (manual add + discovery merged)") {
onMainScreen { tokenRowWithTitle(tetherTitle).assertIsDisplayed() }
}
step("Assert assets list contains exactly $expectedTokensCount tokens (no duplicate after merge)") {
onMainScreen { assertTokensCount(expectedTokensCount) }
}
}
}
@AllureId("9282")
@DisplayName("Hot wallet: re-import existing wallet — 200 OK, no Discovery, tokens from backend")
@Test
fun reimportExistingHotWalletTest() {
setupHooks(
additionalBeforeAppLaunchSection = {
setWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO, state = SCENARIO_STATE_ALREADY_EXISTS)
setWireMockScenarioState(USER_TOKENS_API_SCENARIO, state = SCENARIO_STATE_STARTED)
setWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO, state = SCENARIO_STATE_EMPTY)
},
additionalAfterSection = {
resetWireMockScenarioState(CREATE_USER_WALLET_API_SCENARIO)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(MORALIS_EVM_TOKEN_BALANCES_API_SCENARIO)
},
).run {
step("Import an existing hot wallet from seed phrase") {
openMainScreenWithExistingHotWallet(SEED_PHRASE_12)
}
step("Assert tokens from backend are displayed immediately") {
BACKEND_PRE_POPULATED_TOKENS.forEach { token ->
onMainScreen { tokenRowWithTitle(token).assertIsDisplayed() }
}
}
step("Assert 'Restoring' loader is NOT displayed (discovery did not start)") {
onMainScreen { restoringProgressText.assertIsNotDisplayed() }
}
step("Assert 'Wallet successfully imported' banner is NOT displayed") {
onMainScreen { walletImportedBanner.assertIsNotDisplayed() }
}
}
}
}

View file

@ -2,7 +2,9 @@ package com.tangem.tests.main
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.SwipeDirection
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.swipeVertical
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.openMainScreen import com.tangem.scenarios.openMainScreen
@ -37,7 +39,7 @@ class MainScreenTest : BaseTestCase() {
} }
@AllureId("8748") @AllureId("8748")
@DisplayName("Main: check 'Organize tokens' button with single token no accounts") @DisplayName("Main: check 'Add & Manage' button with single token no accounts")
@Test @Test
fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() { fun checkOrganizeTokensButtonWithSingleTokenNoAccountsTest() {
val scenarioState = "Cardano" val scenarioState = "Cardano"
@ -58,14 +60,14 @@ class MainScreenTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Assert 'Add & Manage' button is not displayed") { step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsNotDisplayed()} onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
} }
} }
} }
@AllureId("8749") @AllureId("8749")
@DisplayName("Main: check 'Organize tokens' button with single token two accounts") @DisplayName("Main: check 'Add & Manage' button with single token two accounts")
@Test @Test
fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() { fun checkOrganizeTokensButtonWithSingleTokenMultiAccountsTest() {
val scenarioState = "TwoAccountsSingleTokenEach" val scenarioState = "TwoAccountsSingleTokenEach"
@ -84,10 +86,10 @@ class MainScreenTest : BaseTestCase() {
openMainScreen() openMainScreen()
} }
step("Assert 'Add & Manage' button is displayed") { step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed() } onMainScreen { addAndManageButton().assertIsDisplayed() }
} }
step("Click 'Add & Manage' button") { step("Click 'Add & Manage' button") {
onMainScreen { addAndManageButtonNode.clickWithAssertion() } onMainScreen { addAndManageButton().clickWithAssertion() }
} }
step("Assert 'Organize tokens' option is not displayed (nothing to organize)") { step("Assert 'Organize tokens' option is not displayed (nothing to organize)") {
onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() } onAddAndManageBottomSheet { organizeTokensButton.assertIsNotDisplayed() }
@ -99,7 +101,7 @@ class MainScreenTest : BaseTestCase() {
} }
@AllureId("8750") @AllureId("8750")
@DisplayName("Main: check 'Organize tokens' button with multiple tokens two accounts") @DisplayName("Main: check 'Add & Manage' button with multiple tokens two accounts")
@Test @Test
fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() { fun checkOrganizeTokensButtonWithMultipleTokensMultiAccountsTest() {
val scenarioState = "TwoAccountsMixed" val scenarioState = "TwoAccountsMixed"
@ -117,8 +119,11 @@ class MainScreenTest : BaseTestCase() {
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
} }
step("Swipe up") {
swipeVertical(SwipeDirection.UP, startHeightRatio = 0.6f, endHeightRatio = 0.1f)
}
step("Assert 'Add & Manage' button is displayed") { step("Assert 'Add & Manage' button is displayed") {
onMainScreen { addAndManageButtonNode.assertIsDisplayed()} onMainScreen { addAndManageButtonNode.assertIsDisplayed() }
} }
} }
} }

View file

@ -16,7 +16,7 @@ import org.junit.Test
class WarningsTest : BaseTestCase() { class WarningsTest : BaseTestCase() {
@AllureId("184") @AllureId("184")
@DisplayName("Token list: hide token by long tap") @DisplayName("Warnings: missing address warning")
@Test @Test
fun checkUnavailableNetworksWarningTest() { fun checkUnavailableNetworksWarningTest() {
val scenarioState = "MissingDerivation" val scenarioState = "MissingDerivation"
@ -38,9 +38,6 @@ class WarningsTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses(isBalanceAvailable = false) synchronizeAddresses(isBalanceAvailable = false)
} }
step("Assert 'Missing addresses' notification icon is displayed") {
onMainScreen { missingAddressNotificationIcon.assertIsDisplayed() }
}
step("Assert 'Missing addresses' notification title is displayed") { step("Assert 'Missing addresses' notification title is displayed") {
onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() } onMainScreen { missingAddressNotificationTitle.assertIsDisplayed() }
} }

View file

@ -1,5 +1,6 @@
package com.tangem.tests.markets package com.tangem.tests.markets
import androidx.compose.ui.test.ExperimentalTestApi
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.annotations.ApiEnv import com.tangem.common.annotations.ApiEnv
import com.tangem.common.annotations.ApiEnvConfig import com.tangem.common.annotations.ApiEnvConfig
@ -39,6 +40,7 @@ class MarketsExchangesTest : BaseTestCase() {
} }
} }
@OptIn(ExperimentalTestApi::class)
@Test @Test
@AllureId("56") @AllureId("56")
@ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD)) @ApiEnv(ApiEnvConfig(ApiConfig.ID.TangemTech, ApiEnvironment.PROD))
@ -53,16 +55,15 @@ class MarketsExchangesTest : BaseTestCase() {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' screen") { step("Open 'Markets' screen") {
onMainScreen { searchThroughMarketPlaceholder.performClick() } onMainScreen { marketsSheetDragHandle.clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Click on '$tokenName' token") { step("Click on '$tokenName' token") {
onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() } onMarketsScreen { tokenWithTitle(tokenName).clickWithAssertion() }
waitForIdle() waitForIdle()
} }
step("Scroll down") { step("Scroll to 'Listed on exchanges' block") {
swipeVertical(SwipeDirection.UP) onMarketsScreen { scrollToListedOnBlock() }
swipeVertical(SwipeDirection.UP)
} }
step("Assert 'Listed on exchanges' block has title") { step("Assert 'Listed on exchanges' block has title") {
onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() } onMarketsScreen { listedOnBlockContainer.assertIsDisplayed() }

View file

@ -227,7 +227,7 @@ class RecentBlockTest : BaseTestCase() {
val sendAmount = "1" val sendAmount = "1"
val txHistoryScenarioState = "11OutgoingTransactions" val txHistoryScenarioState = "11OutgoingTransactions"
val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq" val recipientAddressBase = "DJ2TaZ5vvp3mBLugUpKjVM3pRBLi4uYaq"
val shortenedRecipientAddress = "DJ2TaZ5vvp3mBLugU...Li4uYaq123456789b" val longRecipientAddress = recipientAddressBase + "123456789b"
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
@ -261,7 +261,7 @@ class RecentBlockTest : BaseTestCase() {
checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1) checkRecentAddressItem(address = DOGECOIN_ADDRESS, description = recentTransactionAmount1)
} }
step("Check recent address item №2") { step("Check recent address item №2") {
checkRecentAddressItem(address = shortenedRecipientAddress, description = recentTransactionAmount2) checkRecentAddressItem(address = longRecipientAddress, description = recentTransactionAmount2)
} }
step("Check recent address item №3") { step("Check recent address item №3") {
checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2) checkRecentAddressItem(address = recipientAddressBase + "k", description = recentTransactionAmount2)

View file

@ -246,8 +246,11 @@ class SendAddressScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)

View file

@ -4,6 +4,7 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
@ -46,8 +47,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {
@ -123,8 +127,11 @@ class SendConfirmScreenTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
} }
step("Click on 'Send' button") { step("Click on 'Transfer' button") {
onTokenDetailsScreen { sendButton().performClick() } onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
} }
step("Type '$inputAmount' in input text field") { step("Type '$inputAmount' in input text field") {
onSendScreen { onSendScreen {
@ -320,4 +327,42 @@ class SendConfirmScreenTest : BaseTestCase() {
} }
} }
} }
@AllureId("557")
@DisplayName("Send (Confirm screen): send a second transaction while the first is still pending")
@Test
fun sendSecondTransactionWhileFirstActiveTest() {
val tokenName = "Ethereum"
val inputAmount = "0.001"
setupHooks().run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
// Hold-to-confirm is swallowed while the fee is still settling — wait for it to load first.
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Open the send flow again from token details") {
openSendFromTokenDetails()
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
} }

View file

@ -5,6 +5,7 @@ import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.POLKADOT_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_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.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
@ -281,13 +282,13 @@ class SendFeeScreenTest : BaseTestCase() {
fun checkNetworkFeeBottomSheetForBitcoinTest() { fun checkNetworkFeeBottomSheetForBitcoinTest() {
val tokenName = "Bitcoin" val tokenName = "Bitcoin"
val tokenAmount = "0.00000001" val tokenAmount = "0.00000001"
val feeAmount = "$2.86" val feeAmount = "$0.48"
val fiatFeeAmount = "$0.24" val fiatFeeAmount = "$0.24"
val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market) val marketSelectorItem = getResourceString(R.string.common_fee_selector_option_market)
val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast) val fastSelectorItem = getResourceString(R.string.common_fee_selector_option_fast)
val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow) val slowSelectorItem = getResourceString(R.string.common_fee_selector_option_slow)
val feeUpTo = getResourceString(R.string.send_max_fee) val feeUpTo = getResourceString(R.string.send_max_fee)
val feeUpToValue = "0.0000264 BTC" val feeUpToValue = "0.0000044 BTC"
val newFeeUpToValue = "0.0000022 BTC" val newFeeUpToValue = "0.0000022 BTC"
val satoshi = getResourceString(R.string.send_satoshi_per_byte_title) val satoshi = getResourceString(R.string.send_satoshi_per_byte_title)
val satoshiValue = "2" val satoshiValue = "2"
@ -443,4 +444,30 @@ class SendFeeScreenTest : BaseTestCase() {
} }
} }
} }
@AllureId("547")
@DisplayName("Send (Fee screen): network fee recalculates on speed switch and sends")
@Test
fun recalculateFeeOnSpeedSwitchAndSendTest() {
val tokenName = "Ethereum"
val inputAmount = "0.8"
setupHooks().run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$inputAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = inputAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
val marketFee = getNetworkFeeAmount()
step("Switch the network fee to 'Fast'") {
switchFeeToFastAndApply()
}
assertNetworkFeeChanged(marketFee)
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
} }

View file

@ -0,0 +1,92 @@
package com.tangem.tests.send.feeScreen
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.TERRA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Completing a send paid with a fee in the token itself (no native fee coin), on a hot wallet:
* VeChain's VeThor and Terra Classic's TerraClassicUSD.
*/
@HiltAndroidTest
class SendTokenFeeTest : BaseTestCase() {
private val tokenAmount = "1"
@AllureId("4907")
@DisplayName("Send (Fee in token): send VeThor and complete the transaction")
@Test
fun sendVeThorWithFeeInTokenTest() {
val tokenName = "VeThor"
val scenarioState = "Vechain"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
assertNetworkFeeContains("\$")
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("4908")
@DisplayName("Send (Fee in token): send TerraClassicUSD and complete the transaction")
@Test
fun sendTerraClassicUsdWithFeeInTokenTest() {
val tokenName = "TerraClassicUSD"
val scenarioState = "Terra"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openSendScreenWithHotWallet(seedPhrase = SVS_SEED_PHRASE_12, tokenName = tokenName)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = TERRA_RECIPIENT_ADDRESS)
}
waitUntilNetworkFeeIsStable { readNetworkFeeAmount() }
assertNetworkFeeContains("\$")
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
}

View file

@ -0,0 +1,317 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
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.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.enterAmountAndOpenSendConfirm
import com.tangem.scenarios.enterRecipientAndOpenSendConfirm
import com.tangem.scenarios.openSendScreen
import com.tangem.scenarios.selectStablecoinAsFeeToken
import com.tangem.screens.onSendConfirmScreen
import com.tangem.screens.onSendFeeSelectorBottomSheet
import com.tangem.screens.onSendScreen
import com.tangem.screens.onSendSelectNetworkFeeBottomSheet
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless network-fee behaviour on the send summary (fee selector): availability, calculation,
* speed options, switching the fee token, and balance-driven notifications. All run on the default
* (cold) wallet without signing a transaction.
*/
@HiltAndroidTest
class GaslessFeeTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val nativeTokenName = "Polygon"
private val tokenAmount = "1"
@AllureId("5061")
@DisplayName("Gasless: Network fee on summary is selectable and the stablecoin is available for the fee")
@Test
fun checkNetworkFeeTokenSelectionAvailableTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
}
}
@AllureId("5062")
@DisplayName("Gasless: network fee for a stablecoin is calculated and shown in the stablecoin")
@Test
fun checkFeeCalculatedInStablecoinTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert the fee is shown under the '$marketSpeed' speed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$tokenName' (not in the coin) on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(tokenName).assertIsDisplayed()
feeAmount.assertIsDisplayed()
}
}
}
}
}
@AllureId("5064")
@DisplayName("Gasless: only Market speed is available when paying the fee with a stablecoin")
@Test
fun checkOnlyMarketSpeedAvailableForStablecoinFeeTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Click on 'Network fee' block") {
onSendConfirmScreen {
feeSelectorBlock.assertIsDisplayed()
feeSelectorBlock.performClick()
}
}
step("Assert 'Network fee' bottom sheet is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Assert 'Network fee' bottom sheet is displayed after token selection") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() }
}
}
step("Assert '$marketSpeed' speed is displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).assertIsDisplayed() }
}
step("Assert '$fastSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed() }
}
step("Assert '$slowSpeed' speed is not displayed") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed() }
}
step("Click on '$marketSpeed' fee row") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' bottom sheet did not open for stablecoin fee") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
}
}
}
@AllureId("5068")
@DisplayName("Gasless: switching the fee token back to the coin restores the standard fee flow")
@Test
fun checkSwitchFeeTokenBackToCoinTest() {
val nativeSymbol = "POL"
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Open the fee token selector again via the '$tokenName' fee token") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
step("Switch the fee token back to '$nativeTokenName'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Click on 'Apply' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
}
step("Assert the network fee is now paid in '$nativeSymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification is not displayed (standard fee flow)") {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
@AllureId("5063")
@DisplayName("Gasless: insufficient stablecoin balance to cover the fee shows error and blocks send")
@Test
fun checkInsufficientBalanceForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
}
}
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
}
}
}
@AllureId("5097")
@DisplayName("Gasless: no insufficient-coin-for-fee notification is shown when gasless covers the fee")
@Test
fun checkNoInsufficientCoinNotificationWhenGaslessTest() {
val coinBalanceScenario = "polygon_coin_balance"
val zeroBalanceState = "ZeroBalance"
val feeBlockedTitlePart = getResourceString(R.string.warning_send_blocked_funds_for_fee_title, "X")
.substringAfter("X ")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(coinBalanceScenario)
}
).run {
step("Set WireMock scenario '$coinBalanceScenario' to '$zeroBalanceState'") {
setWireMockScenarioState(scenarioName = coinBalanceScenario, state = zeroBalanceState)
}
step("Open 'Send' screen for '$tokenName'") {
openSendScreen(tokenName = tokenName, mockState = scenarioState)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Assert the fee defaults to '$tokenName' (gasless covers the missing coin)") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(tokenName).assertIsDisplayed() }
}
}
step("Assert the insufficient-coin-for-fee notification is not shown") {
onSendConfirmScreen { warningTitleContaining(feeBlockedTitlePart).assertIsNotDisplayed() }
}
step("Assert 'Send' button is enabled") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
}
}
}

View file

@ -0,0 +1,189 @@
package com.tangem.tests.send.gasless
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.ETHEREUM_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless send lifecycle: signing and broadcasting a stablecoin-fee transaction (hot wallet),
* the max-amount fee reservation, and the completed gasless transaction in the token history.
*/
@HiltAndroidTest
class GaslessSendTest : BaseTestCase() {
private val scenarioState = "PolygonUSDC"
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val tokenAmount = "1"
@AllureId("5069")
@DisplayName("Gasless: max amount reserves the stablecoin fee and stays sendable")
@Test
fun checkMaxAmountSendTest() {
val feeCoverageTitle = getResourceString(R.string.send_network_fee_warning_title)
val feeCoverageMessagePart = getResourceString(R.string.common_network_fee_warning_content, "", "")
.substringBefore("(")
.trim()
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Click on 'Max' button") {
onSendScreen { maxButton.performClick() }
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Enter the recipient and open the 'Send confirm' screen") {
enterRecipientAndOpenSendConfirm(ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert 'Network fee coverage' notification title is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { warningTitle(feeCoverageTitle).assertIsDisplayed() }
}
}
step("Assert 'Network fee coverage' notification text is displayed (amount reduced by fee)") {
onSendConfirmScreen { warningMessageContaining(feeCoverageMessagePart).assertIsDisplayed() }
}
step("Assert 'Send' button is enabled (enough left for the fee)") {
onSendConfirmScreen { sendButton.assertIsEnabled() }
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5065")
@DisplayName("Gasless: sign and send a stablecoin transaction with the stablecoin fee")
@Test
fun checkSignAndSendGaslessTransactionTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Open the send flow for '$tokenName' on an existing hot wallet") {
openGaslessSendScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
userTokensState = hotWalletTokensState,
quotesState = scenarioState,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = ETHEREUM_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert gasless fee is paid in '$currencySymbol' and 'Send' is enabled") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeBlockCurrency(currencySymbol).assertIsDisplayed()
sendButton.assertIsEnabled()
}
}
}
step("Sign, send and open the 'Transaction sent' screen") {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
}
@AllureId("5066")
@DisplayName("Gasless: completed gasless transaction is shown in token transaction history")
@Test
fun checkGaslessTransactionInHistoryTest() {
val sentAmount = "1.00"
val gaslessFeeAmount = "0.10"
val sentTitle = getResourceString(R.string.common_sent)
val gaslessFeeTitle = getResourceString(R.string.gasless_transaction_fee)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = scenarioState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$scenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = scenarioState)
}
step("Open 'Main' screen") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Assert 'Token details' screen is displayed") {
onTokenDetailsScreen { screenContainer.assertIsDisplayed() }
}
step("Wait for gasless '$gaslessFeeTitle' transaction in history") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTxHistoryScreen { transactionItem(gaslessFeeTitle).assertIsDisplayed() }
}
}
step("Assert '$sentTitle' transaction is displayed") {
onTxHistoryScreen { transactionItem(sentTitle).assertIsDisplayed() }
}
step("Assert '$sentTitle' amount '$sentAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(sentTitle).assertTextContains(sentAmount, substring = true)
transactionCurrency(sentTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' amount '$gaslessFeeAmount' is displayed in '$currencySymbol'") {
onTxHistoryScreen {
transactionAmount(gaslessFeeTitle).assertTextContains(gaslessFeeAmount, substring = true)
transactionCurrency(gaslessFeeTitle).assertTextEquals(currencySymbol)
}
}
step("Assert gasless '$gaslessFeeTitle' status is confirmed") {
onTxHistoryScreen { transactionConfirmedStatus(gaslessFeeTitle).assertIsDisplayed() }
}
}
}
}

View file

@ -0,0 +1,256 @@
package com.tangem.tests.send.sendViaSwap
import com.tangem.common.BaseTestCase
import com.tangem.common.R
import com.tangem.common.constants.TestConstants.BITCOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.PROVIDERS_API_SCENARIO
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
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 dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless send-via-swap: paying the network fee with the stablecoin while converting it through an
* express swap. Covers the fee-token selection on the swap summary, the stablecoin balance validation
* against the gasless fee, and the full signed swap-and-send on a hot wallet.
*/
@HiltAndroidTest
class GaslessSendViaSwapTest : BaseTestCase() {
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val nativeTokenName = "Polygon"
private val swapTokenName = "Bitcoin"
private val mainNetwork = "MAIN"
private val providerName = "Changelly"
private val tokenAmount = "1"
private val hotWalletTokensState = "PolygonUSDCHotWallet"
private val quotesState = "PolygonUSDC"
private val assetsScenarioName = "express_api_assets"
private val assetsExchangeEnabledState = "BitcoinExchangeEnabled"
private val providersState = "HotWalletSvS"
@AllureId("5120")
@DisplayName("Gasless Send via Swap: the network fee is selectable and payable with the stablecoin")
@Test
fun checkFeeTokenSelectionForSwapTest() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen {
feeSelectorTitle.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Click on 'Network fee' block") {
onSendConfirmScreen { feeSelectorBlock.performClick() }
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' bottom sheet is displayed") {
onSendFeeSelectorBottomSheet { chooseTokenTitle.assertIsDisplayed() }
}
step("Assert '$tokenName' is available for the fee payment") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).assertIsDisplayed() }
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is calculated in '$currencySymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5121")
@DisplayName("Gasless Send via Swap: insufficient stablecoin balance to cover the fee blocks the swap")
@Test
fun checkBalanceValidationForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
}
}
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
}
}
}
@AllureId("5122")
@DisplayName("Gasless Send via Swap: sign and send a swap paying the fee with the stablecoin")
@Test
fun checkSendViaSwapFinalScreenAndSendTest() {
val exchangeStatusScenario = "exchange_status_provider"
val changellyStatusState = "Changelly"
val expressStatusItemTitle = getResourceString(R.string.express_exchange_by, providerName)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(PROVIDERS_API_SCENARIO)
resetWireMockScenarioState(exchangeStatusScenario)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$PROVIDERS_API_SCENARIO' to '$providersState'") {
setWireMockScenarioState(scenarioName = PROVIDERS_API_SCENARIO, state = providersState)
}
step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") {
setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState)
}
step("Open the send-via-swap flow for '$tokenName' on an existing hot wallet") {
openSendViaSwapScreenWithHotWallet(
seedPhrase = SVS_SEED_PHRASE_12,
tokenName = tokenName,
swapTokenName = swapTokenName,
networkName = swapTokenName,
networkType = mainNetwork,
)
}
step("Enter amount '$tokenAmount' and open the 'Send confirm' screen") {
enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS)
}
step("Pay the network fee with '$tokenName' via the fee selector") {
selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName)
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the sent '$tokenName' amount is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { primaryAmount.assertIsDisplayed() }
}
}
step("Assert the recipient address is displayed") {
onSendConfirmScreen { recipientAddress(BITCOIN_RECIPIENT_ADDRESS).assertIsDisplayed() }
}
step("Assert the amount to receive after the swap is displayed") {
onSendConfirmScreen { secondaryAmount.assertIsDisplayed() }
}
step("Assert the network fee is paid in '$currencySymbol'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
step("Sign, send and open the 'Transaction sent' screen") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendSuccessScreenViaLongClickOnSendButton()
}
}
step("Check 'Send via swap' success screen") {
checkSendViaSwapSuccessScreen()
}
step("Click on 'Close' button") {
onSendSuccessScreen { closeButton.performClick() }
}
step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() }
}
}
}
}
}

View file

@ -8,6 +8,7 @@ import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.SOLANA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12 import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.XRP_ACTIVATED_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.extractText import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
@ -665,4 +666,163 @@ class SendViaSwapTest : BaseTestCase() {
} }
} }
} }
@AllureId("9924")
@DisplayName("Send via Swap: editing the memo persists the latest value")
@Test
fun sendViaSwapMemoEditPersistsLatestValueTest() {
val tokenName = "Bitcoin"
val swapTokenName = "XRP"
val networkName = "XRP Ledger"
val inputAmount = "0.001"
val recipientAddress = XRP_ACTIVATED_RECIPIENT_ADDRESS
val memoFirst = "11111111"
val memoSecond = "22222222"
val hotWalletScenarioState = "HotWalletSvS"
val quotesScenarioState = "Ripple"
val bitcoinBalanceScenarioName = "bitcoin_utxo"
val bitcoinBalanceScenarioState = "BalanceHotWalletSvS"
val assetsScenarioName = "express_api_assets"
val assetsScenarioState = "BitcoinExchangeEnabledWithXRP"
val coinsScenarioName = "coins_api"
val coinsScenarioState = "WithXRP"
val providersScenarioName = "networks_providers"
val providersScenarioState = "HotWalletSvS"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(bitcoinBalanceScenarioName)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(coinsScenarioName)
resetWireMockScenarioState(providersScenarioName)
}
).run {
step("Set WireMock scenario: '$USER_TOKENS_API_SCENARIO' to state: '$hotWalletScenarioState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletScenarioState)
}
step("Set WireMock scenario: '$QUOTES_API_SCENARIO' to state: '$quotesScenarioState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesScenarioState)
}
step("Set WireMock scenario: '$bitcoinBalanceScenarioName' to state: '$bitcoinBalanceScenarioState'") {
setWireMockScenarioState(scenarioName = bitcoinBalanceScenarioName, state = bitcoinBalanceScenarioState)
}
step("Set WireMock scenario: '$assetsScenarioName' to state: '$assetsScenarioState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsScenarioState)
}
step("Set WireMock scenario: '$coinsScenarioName' to state: '$coinsScenarioState'") {
setWireMockScenarioState(scenarioName = coinsScenarioName, state = coinsScenarioState)
}
step("Set WireMock scenario: '$providersScenarioName' to state: '$providersScenarioState'") {
setWireMockScenarioState(scenarioName = providersScenarioName, state = providersScenarioState)
}
step("Open 'Main Screen' with existing hot wallet") {
openMainScreenWithExistingHotWallet(SVS_SEED_PHRASE_12)
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).clickWithAssertion() }
}
step("Click on 'Transfer' button") {
onTokenDetailsScreen { transferButton.clickWithAssertion() }
}
step("Click on 'Send' button in bottom sheet") {
onTransferBottomSheet { sendButton.clickWithAssertion() }
}
step("Click on 'Swap to another token' button") {
onSendScreen { swapToAnotherTokenButton.performClick() }
}
step("Type '$swapTokenName' in search text field") {
onSendViaSwapScreen {
searchField.performClick()
searchField.performTextInput(swapTokenName)
}
}
step("Click on token: '$swapTokenName'") {
onSendViaSwapScreen { tokenItem(swapTokenName).performClick() }
}
step("Click on '$networkName' network") {
onChooseNetworkBottomSheet { networkItem(networkName).performClick() }
}
step("Type '$inputAmount' in text field") {
onSendScreen { amountInputTextField.performTextInput(inputAmount) }
}
step("Click on 'Next' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendScreen {
nextButton.assertIsEnabled()
nextButton.performClick()
}
}
}
step("Type recipient address") {
onSendAddressScreen { addressTextField.performTextReplacement(recipientAddress) }
}
step("Type '$memoFirst' in 'Destination Tag' field") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendAddressScreen { destinationTagTextField.performTextReplacement(memoFirst) }
}
}
step("Assert 'Destination Tag' field value is '$memoFirst'") {
onSendAddressScreen { destinationTagTextField.assertTextContains(memoFirst) }
}
step("Open 'Send confirm' screen via 'Next' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaNextButton()
}
}
step("Assert recipient memo on 'Send confirm' screen contains '$memoFirst'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { recipientMemo.assertTextContains(memoFirst, substring = true) }
}
}
// Edit-mode re-entry from Confirm uses the 'Continue' button, not 'Next'.
step("Click on recipient block to edit memo") {
onSendConfirmScreen { recipientBlock.performClick() }
}
step("Replace 'Destination Tag' field value with '$memoSecond'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendAddressScreen { destinationTagTextField.performTextReplacement(memoSecond) }
}
}
step("Assert 'Destination Tag' field value is '$memoSecond'") {
onSendAddressScreen { destinationTagTextField.assertTextContains(memoSecond) }
}
step("Return to 'Send confirm' screen via 'Continue' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaContinueButton()
}
}
step("Assert recipient memo on 'Send confirm' screen contains '$memoSecond'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { recipientMemo.assertTextContains(memoSecond, substring = true) }
}
}
step("Click on recipient block to edit memo") {
onSendConfirmScreen { recipientBlock.performClick() }
}
step("Replace 'Destination Tag' field value back with '$memoFirst'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendAddressScreen { destinationTagTextField.performTextReplacement(memoFirst) }
}
}
step("Assert 'Destination Tag' field value is '$memoFirst'") {
onSendAddressScreen { destinationTagTextField.assertTextContains(memoFirst) }
}
step("Return to 'Send confirm' screen via 'Continue' button") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaContinueButton()
}
}
step("Assert recipient memo on 'Send confirm' screen contains '$memoFirst'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendConfirmScreen { recipientMemo.assertTextContains(memoFirst, substring = true) }
}
}
}
}
} }

View file

@ -4,9 +4,11 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.DOGECOIN_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.DOGECOIN_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen import com.tangem.screens.onSendScreen
@ -20,8 +22,8 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class DogecoinWarningsTest : BaseTestCase() { class DogecoinWarningsTest : BaseTestCase() {
private val tokenName = "Dogecoin" private val tokenName = "Dogecoin"
private val amountToLeaveLessThanDust = "5.78654978" private val amountToLeaveLessThanDust = "5.7045"
private val amountToLeaveMoreThanDust = "5.7" private val amountToLeaveMoreThanDust = "5.6"
private val amountGreaterThanDust = "0.02" private val amountGreaterThanDust = "0.02"
private val amountLessThanDust = "0.005" private val amountLessThanDust = "0.005"
private val dustAmount = "DOGE 0.01" private val dustAmount = "DOGE 0.01"
@ -56,8 +58,10 @@ class DogecoinWarningsTest : BaseTestCase() {
step("Type address in input text field") { step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) }
} }
step("Click on 'Next' button") { step("Open 'Send confirm' screen via 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() } flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaNextButton()
}
} }
step("Assert 'Invalid amount warning' is displayed") { step("Assert 'Invalid amount warning' is displayed") {
checkSendWarning( checkSendWarning(
@ -93,8 +97,10 @@ class DogecoinWarningsTest : BaseTestCase() {
step("Type address in input text field") { step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) } onSendAddressScreen { addressTextField.performTextReplacement(DOGECOIN_RECIPIENT_ADDRESS) }
} }
step("Click on 'Next' button") { step("Open 'Send confirm' screen via 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() } flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaNextButton()
}
} }
step("Assert 'Invalid amount warning' is not displayed") { step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning( checkSendWarning(

View file

@ -0,0 +1,290 @@
package com.tangem.tests.send.warnings
import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen
import com.tangem.wallet.R
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
@HiltAndroidTest
class KaspaDustWarningsTest : BaseTestCase() {
private val tokenName = "Kaspa"
private val amountLessThanMinimum = "0.1"
private val amountExactlyMinimum = "0.2"
private val amountMoreThanMinimum = "0.3"
private val amountToLeaveMoreThanMinimumChange = "0.5"
private val amountToLeaveExactlyMinimumChange = "0.79"
private val amountToLeaveLessThanMinimumChange = "0.85"
private val kaspaUTXOScenarioName = "kaspa_utxo"
private val dustState = "dust"
private val dustAmount = "KAS 0.20"
private val invalidAmountTitle = getResourceString(R.string.send_notification_invalid_amount_title)
private val invalidAmountMessage = getResourceString(
R.string.send_notification_invalid_minimum_amount_text,
dustAmount, dustAmount
)
@AllureId("4685")
@DisplayName("Warnings: invalid amount warning is displayed, when sending less than minimum amount (Kaspa)")
@Test
fun warningIsDisplayedWhenSendingLessThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountLessThanMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountLessThanMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage
)
}
}
}
@AllureId("9860")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when sending exactly minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenSendingExactlyMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountExactlyMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountExactlyMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4683")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when sending more than minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenSendingMoreThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountMoreThanMinimum' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountMoreThanMinimum)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4684")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when change is more than minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenChangeIsMoreThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveMoreThanMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveMoreThanMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
@AllureId("4682")
@DisplayName("Warnings: invalid amount warning is displayed, when change is less than minimum amount (Kaspa)")
@Test
fun warningIsDisplayedWhenChangeIsLessThanMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveLessThanMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveLessThanMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage
)
}
}
}
@AllureId("9861")
@DisplayName("Warnings: invalid amount warning is NOT displayed, when change is exactly minimum amount (Kaspa)")
@Test
fun warningIsNotDisplayedWhenChangeIsExactlyMinimum() {
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(kaspaUTXOScenarioName)
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
}
).run {
step("Set WireMock scenario: '$kaspaUTXOScenarioName' to state: '$dustState'") {
setWireMockScenarioState(scenarioName = kaspaUTXOScenarioName, state = dustState)
}
step("Open 'Send Screen' with token: $tokenName") {
openSendScreen(tokenName)
}
step("Type '$amountToLeaveExactlyMinimumChange' in input text field") {
onSendScreen {
amountInputTextField.performClick()
amountInputTextField.performTextReplacement(amountToLeaveExactlyMinimumChange)
}
}
step("Click on 'Next' button") {
onSendScreen { nextButton.clickWithAssertion() }
}
step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
}
step("Click on 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() }
}
step("Assert 'Invalid amount warning' is not displayed") {
checkSendWarning(
title = invalidAmountTitle,
message = invalidAmountMessage,
isDisplayed = false
)
}
}
}
}

View file

@ -4,10 +4,12 @@ import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.KASPA_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendScreen import com.tangem.screens.onSendScreen
@ -145,8 +147,10 @@ class KaspaWarningsTest : BaseTestCase() {
step("Type address in input text field") { step("Type address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) } onSendAddressScreen { addressTextField.performTextReplacement(KASPA_RECIPIENT_ADDRESS) }
} }
step("Click on 'Next' button") { step("Click 'Next' button until 'Send Confirm' screen opens") {
onSendAddressScreen { nextButton.clickWithAssertion() } composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
runCatching { openSendConfirmScreenViaNextButton() }.isSuccess
}
} }
step("Assert 'UTXO limit warning' is displayed") { step("Assert 'UTXO limit warning' is displayed") {
checkSendWarning( checkSendWarning(

View file

@ -3,11 +3,13 @@ package com.tangem.tests.send.warnings
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XLM_ACTIVATED_RECIPIENT_ADDRESS
import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS import com.tangem.common.constants.TestConstants.XLM_NON_ACTIVATED_RECIPIENT_ADDRESS
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.scenarios.checkSendWarning import com.tangem.scenarios.checkSendWarning
import com.tangem.scenarios.openSendConfirmScreenViaNextButton
import com.tangem.scenarios.openSendScreen import com.tangem.scenarios.openSendScreen
import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendAddressScreen
import com.tangem.screens.onSendConfirmScreen import com.tangem.screens.onSendConfirmScreen
@ -110,8 +112,10 @@ class StellarWarningsTest : BaseTestCase() {
step("Type non activated address in input text field") { step("Type non activated address in input text field") {
onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) } onSendAddressScreen { addressTextField.performTextReplacement(XLM_NON_ACTIVATED_RECIPIENT_ADDRESS) }
} }
step("Click on 'Next' button") { step("Open 'Send confirm' screen via 'Next' button") {
onSendAddressScreen { nextButton.clickWithAssertion() } flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
openSendConfirmScreenViaNextButton()
}
} }
step("Assert 'Invalid reserve amount warning' is not displayed") { step("Assert 'Invalid reserve amount warning' is not displayed") {
checkSendWarning( checkSendWarning(

View file

@ -0,0 +1,542 @@
package com.tangem.tests.swap
import com.tangem.common.BaseTestCase
import com.tangem.common.R as CommonR
import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO
import com.tangem.common.constants.TestConstants.SVS_SEED_PHRASE_12
import com.tangem.common.constants.TestConstants.USER_TOKENS_API_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.extractText
import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.ui.R
import com.tangem.scenarios.*
import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest
import io.github.kakaocup.kakao.common.utilities.getResourceString
import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Test
/**
* Gasless swap on a CEX route, paying the swap network fee with a stablecoin. Covers the best-rate
* block / unchanged rate (5117), the fee-token selector bottom sheet (5111), the network-fee
* selection for a USDC -> POL swap (5110), the signed swap reaching the provider (5116, hot wallet),
* the insufficient-stablecoin-balance-for-fee error (5118), the stablecoin fee shown with its
* fiat equivalent (5112), switching the fee token between the coin and the stablecoin (5114), and the
* fee-selection options when paying with a token (5115), and the max amount reserving the fee (5113).
* Validation cases run on the default (cold) wallet without signing.
*/
@HiltAndroidTest
class GaslessSwapTest : BaseTestCase() {
private val tokenName = "USDC"
private val currencySymbol = "USDC"
private val swapTokenName = "Ethereum"
private val nativeTokenName = "Polygon"
private val inputAmount = "50"
private val userTokensState = "PolygonUSDCEthereum"
private val quotesState = "PolygonUSDC"
private val assetsScenarioName = "express_api_assets"
private val assetsExchangeEnabledState = "BitcoinExchangeEnabled"
@AllureId("5117")
@DisplayName("Gasless Swap: CEX best rate stays the same when the fee is paid with a stablecoin")
@Test
fun checkBestRateUnchangedWithStablecoinFeeTest() {
var capturedReceiveAmount: String? = null
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
}
step("Assert 'Best rate' label is displayed") {
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { bestRateText.assertIsDisplayed() }
}
}
step("Capture the received amount (exchange rate) before changing the fee token") {
onSwapTokenScreen { capturedReceiveAmount = receiveAmount.extractText() }
}
step("Pay the network fee with the stablecoin '$tokenName'") {
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
}
step("Assert the received amount (rate) is unchanged after selecting the stablecoin fee") {
val expected = requireNotNull(capturedReceiveAmount) { "Receive amount was not captured" }
onSwapTokenScreen {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { receiveAmount.assertTextEquals(expected) }
}
}
}
}
@AllureId("5111")
@DisplayName("Gasless Swap: fee-token selector — coin has a speed choice, stablecoin only Market, token is selectable")
@Test
fun checkFeeSelectorBottomSheetTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
}
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Assert the '$nativeTokenName' fee coin is displayed") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).assertIsDisplayed() }
}
step("Open 'Choose speed' for the '$nativeTokenName' fee") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' offers multiple speeds ('$marketSpeed', '$fastSpeed') for the coin") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendSelectNetworkFeeBottomSheet {
chooseSpeedTitle.assertIsDisplayed()
regularFeeSelectorItem(marketSpeed).assertIsDisplayed()
regularFeeSelectorItem(fastSpeed).assertIsDisplayed()
}
}
}
step("Select '$marketSpeed' speed") {
onSendSelectNetworkFeeBottomSheet { regularFeeSelectorItem(marketSpeed).performClick() }
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
}
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
onSendFeeSelectorBottomSheet {
chooseTokenTitle.assertIsDisplayed()
feeTokenItem(tokenName).assertIsDisplayed()
}
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Assert only '$marketSpeed' speed is available for the stablecoin fee") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet {
feeSpeedItemTitle(marketSpeed).assertIsDisplayed()
feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed()
feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed()
}
}
}
step("Click on '$marketSpeed' fee row") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' bottom sheet did not open for the stablecoin fee") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is shown in '$currencySymbol' on the swap screen") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5110")
@DisplayName("Gasless Swap: network fee with fee-token selection is shown for a USDC -> POL swap")
@Test
fun checkNetworkFeeSelectionForSwapTest() {
val receiveTokenName = "Polygon"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = receiveTokenName, amount = inputAmount)
}
step("Assert 'Network fee' block with token selection is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
networkFeeBlock.assertIsDisplayed()
selectFeeIcon.assertIsDisplayed()
}
}
}
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet {
chooseTokenTitle.assertIsDisplayed()
feeTokenItem(tokenName).assertIsDisplayed()
}
}
}
}
}
@AllureId("5116")
@DisplayName("Gasless Swap: sign a swap paying the fee with the stablecoin and reach the provider")
@Test
fun checkSignSwapWithStablecoinFeeTest() {
val hotWalletTokensState = "PolygonUSDCHotWallet"
val receiveTokenName = "Polygon"
val providerName = "Changelly"
val exchangeStatusScenario = "exchange_status_provider"
val changellyStatusState = "Changelly"
val expressStatusItemTitle = getResourceString(CommonR.string.express_exchange_by, providerName)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(exchangeStatusScenario)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$hotWalletTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = hotWalletTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Set WireMock scenario '$exchangeStatusScenario' to '$changellyStatusState'") {
setWireMockScenarioState(scenarioName = exchangeStatusScenario, state = changellyStatusState)
}
step("Open the swap amount screen for '$tokenName' -> '$receiveTokenName' on an existing hot wallet") {
openSwapAmountScreen(
fromTokenName = tokenName,
receiveTokenName = receiveTokenName,
amount = inputAmount,
seedPhrase = SVS_SEED_PHRASE_12,
)
}
step("Pay the network fee with the stablecoin '$tokenName'") {
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
}
step("Assert the network fee is paid in '$currencySymbol'") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
feeBlockCurrency(currencySymbol).assertIsDisplayed()
}
}
}
step("Confirm the swap by holding the 'Swap' button and sign") {
confirmSwapByHolding()
}
step("Assert the 'Swap in progress' screen is displayed (transaction sent to provider)") {
onSwapSuccessScreen {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { title.assertIsDisplayed() }
}
}
step("Click on 'Close' button") {
onSwapSuccessScreen { closeButton.performClick() }
}
step("Assert 'Express status' item with title '$expressStatusItemTitle' is displayed") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onTokenDetailsScreen { expressStatusItem(expressStatusItemTitle).assertIsDisplayed() }
}
}
}
}
@AllureId("5118")
@DisplayName("Gasless Swap: insufficient stablecoin balance to cover the fee shows an error and blocks the swap")
@Test
fun checkInsufficientBalanceForFeeTest() {
val usdcBalanceScenario = "polygon_usdc_balance"
val lowBalanceState = "LowBalance"
val lowAmount = "0.0005"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
resetWireMockScenarioState(usdcBalanceScenario)
}
).run {
step("Set WireMock scenario '$usdcBalanceScenario' to '$lowBalanceState'") {
setWireMockScenarioState(scenarioName = usdcBalanceScenario, state = lowBalanceState)
}
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = lowAmount)
}
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Select '$tokenName' as the fee-paying token") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
}
step("Assert 'Not enough funds' error is displayed in the fee selector") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet { notEnoughFundsError.assertIsDisplayed() }
}
}
step("Assert 'Apply' button is disabled (cannot pay the fee with insufficient balance)") {
onSendFeeSelectorBottomSheet { applyButton.assertIsNotEnabled() }
}
}
}
@AllureId("5112")
@DisplayName("Gasless Swap: the stablecoin network fee is shown with its fiat (dollar) equivalent")
@Test
fun checkStablecoinFeeShownWithFiatTest() {
val fiatSign = "$"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
}
step("Pay the network fee with the stablecoin '$tokenName'") {
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
}
step("Assert the network fee is shown in '$currencySymbol' with its fiat ('$fiatSign') equivalent") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
feeBlockCurrency(currencySymbol).assertIsDisplayed()
feeAmount.assertTextContains(fiatSign, substring = true)
}
}
}
}
}
@AllureId("5114")
@DisplayName("Gasless Swap: switching the fee token from the coin to the stablecoin and back updates the summary")
@Test
fun checkSwitchFeeTokenBetweenCoinAndStablecoinTest() {
val nativeSymbol = "POL"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
}
step("Switch the fee token from the coin '$nativeTokenName' to the stablecoin '$tokenName'") {
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
}
step("Assert the network fee is now paid in '$currencySymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
step("Switch the fee token from the stablecoin '$tokenName' back to the coin '$nativeTokenName'") {
switchFeeTokenAndApply(currentFeeToken = tokenName, newFeeToken = nativeTokenName)
}
step("Assert the network fee is back to the coin ('$nativeSymbol') on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { feeBlockCurrency(nativeSymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5115")
@DisplayName("Gasless Swap: fee selector offers token selection and no speed choice when paying with a token")
@Test
fun checkFeeSelectionOptionsTest() {
val marketSpeed = getResourceString(R.string.common_fee_selector_option_market)
val fastSpeed = getResourceString(R.string.common_fee_selector_option_fast)
val slowSpeed = getResourceString(R.string.common_fee_selector_option_slow)
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = inputAmount)
}
step("Open the 'Network fee' bottom sheet") {
openSwapNetworkFeeSelector()
}
step("Click on '$nativeTokenName' fee token to open 'Choose token'") {
onSendFeeSelectorBottomSheet { feeTokenItem(nativeTokenName).performClick() }
}
step("Assert 'Choose token' is displayed and '$tokenName' is available for the fee") {
onSendFeeSelectorBottomSheet {
chooseTokenTitle.assertIsDisplayed()
feeTokenItem(tokenName).assertIsDisplayed()
}
}
step("Select '$tokenName' as the fee-paying token") {
onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() }
}
step("Assert only '$marketSpeed' speed is available for the token fee") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSendFeeSelectorBottomSheet {
feeSpeedItemTitle(marketSpeed).assertIsDisplayed()
feeSpeedItemTitle(fastSpeed).assertIsNotDisplayed()
feeSpeedItemTitle(slowSpeed).assertIsNotDisplayed()
}
}
}
step("Click on '$marketSpeed' fee row") {
onSendFeeSelectorBottomSheet { feeSpeedItemTitle(marketSpeed).performClick() }
}
step("Assert 'Choose speed' bottom sheet did not open when paying with a token") {
onSendSelectNetworkFeeBottomSheet { chooseSpeedTitle.assertIsNotDisplayed() }
}
step("Click on 'Apply' button") {
onSendFeeSelectorBottomSheet { applyButton.performClick() }
}
step("Assert the network fee is shown in '$currencySymbol' on the summary") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen { feeBlockCurrency(currencySymbol).assertIsDisplayed() }
}
}
}
}
@AllureId("5113")
@DisplayName("Gasless Swap: the max amount reserves the stablecoin fee and stays valid without an insufficient error")
@Test
fun checkMaxAmountReservesFeeTest() {
val maxInputAmount = "100"
setupHooks(
additionalAfterSection = {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(assetsScenarioName)
}
).run {
step("Set WireMock scenario '$USER_TOKENS_API_SCENARIO' to '$userTokensState'") {
setWireMockScenarioState(scenarioName = USER_TOKENS_API_SCENARIO, state = userTokensState)
}
step("Set WireMock scenario '$QUOTES_API_SCENARIO' to '$quotesState'") {
setWireMockScenarioState(scenarioName = QUOTES_API_SCENARIO, state = quotesState)
}
step("Set WireMock scenario '$assetsScenarioName' to '$assetsExchangeEnabledState'") {
setWireMockScenarioState(scenarioName = assetsScenarioName, state = assetsExchangeEnabledState)
}
step("Open the swap amount screen for '$tokenName' -> '$swapTokenName' with the max amount '$maxInputAmount'") {
openSwapAmountScreen(fromTokenName = tokenName, receiveTokenName = swapTokenName, amount = maxInputAmount)
}
step("Pay the network fee with the stablecoin '$tokenName'") {
switchFeeTokenAndApply(currentFeeToken = nativeTokenName, newFeeToken = tokenName)
}
step("Assert no insufficient-balance error and 'Swap' is enabled (the fee is reserved from the max amount)") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
onSwapTokenScreen {
insufficientFundsErrorTitle.assertIsNotDisplayed()
swapButton.assertIsEnabled()
}
}
}
}
}
}

View file

@ -3,8 +3,6 @@ package com.tangem.tests.swap
import androidx.compose.ui.test.longClick import androidx.compose.ui.test.longClick
import androidx.test.InstrumentationRegistry.getTargetContext import androidx.test.InstrumentationRegistry.getTargetContext
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT
import com.tangem.common.extensions.assertHasBadge
import com.tangem.common.extensions.restartApp import com.tangem.common.extensions.restartApp
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
@ -18,101 +16,6 @@ import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
class SwapStoriesTest : BaseTestCase() { class SwapStoriesTest : BaseTestCase() {
@AllureId("5453")
@DisplayName("Check 'Swap' button badge on 'Main' screen")
@Test
fun checkMainScreenSwapButtonBadgeTest() {
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
}
}
@AllureId("5454")
@DisplayName("Check 'Swap' button badge on token details screen")
@Test
fun checkTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
}
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
}
}
@AllureId("5455")
@DisplayName("Check 'Swap' button badge on token details in 'Market' screen")
@Test
fun checkMarketTokenDetailsScreenSwapButtonTest() {
val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks().run {
step("Open 'Main Screen'") {
openMainScreen()
}
step("Synchronize addresses") {
synchronizeAddresses()
}
step("Open 'Markets' token details screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
}
step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails)
}
step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() }
}
step("Assert 'Swap' button has not badge") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
}
}
}
@AllureId("5469") @AllureId("5469")
@DisplayName("Check unavailable swap stories on 'Main' screen") @DisplayName("Check unavailable swap stories on 'Main' screen")
@Test @Test
@ -136,9 +39,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Assert 'Swap' button has not badge") {
onMainScreen { swapButton.assertHasBadge(false) }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false) openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = false)
} }
@ -155,9 +55,6 @@ class SwapStoriesTest : BaseTestCase() {
waitForIdle() waitForIdle()
onMainScreen { swapButton.assertIsDisplayed() } onMainScreen { swapButton.assertIsDisplayed() }
} }
step("Assert 'Swap' button has badge") {
onMainScreen { swapButton.assertHasBadge() }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true) openSwapScreen(from = SwapEntryPoint.MainScreen, storiesExist = true)
} }
@ -192,9 +89,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Assert 'Swap' button has not badge") {
onTokenDetailsScreen { swapButton().assertHasBadge(false) }
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
@ -207,13 +101,6 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") { step("Restart app") {
restartApp(packageName) restartApp(packageName)
} }
step("Assert 'Swap' button has badge") {
waitForIdle()
flakySafely(WAIT_UNTIL_TIMEOUT) {
composeTestRule.mainClock.advanceTimeBy(500)
onMainScreen { swapButton.assertHasBadge() }
}
}
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
} }
@ -228,8 +115,6 @@ class SwapStoriesTest : BaseTestCase() {
val scenarioErrorState = "Error" val scenarioErrorState = "Error"
val packageName = getTargetContext().packageName val packageName = getTargetContext().packageName
val tokenName = "Ethereum" val tokenName = "Ethereum"
val badgeShown = "Badge shown"
val badgeHidden = "Badge hidden"
setupHooks( setupHooks(
additionalBeforeAppLaunchSection = { additionalBeforeAppLaunchSection = {
@ -246,16 +131,15 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
} }
step("Assert 'Swap' button has not badge") { step("Assert 'Swap' button is displayed") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeHidden) }
} }
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
step("Click on 'Close' button") { step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
@ -266,16 +150,12 @@ class SwapStoriesTest : BaseTestCase() {
step("Restart app") { step("Restart app") {
restartApp(packageName) restartApp(packageName)
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Assert 'Swap' button is displayed") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName)
}
step("Assert 'Swap' button has badge") {
waitForIdle() waitForIdle()
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertIsDisplayed() } onTokenDetailsScreen { swapButton.assertIsDisplayed() }
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.assertContentDescriptionEquals(badgeShown) }
} }
step("Open 'Swap' screen") { step("Open 'Swap' screen") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = true) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = true)
} }
} }
} }
@ -331,11 +211,8 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on token with name: '$tokenName'") { step("Click on token with name: '$tokenName'") {
onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() } onMainScreen { tokenWithTitleAndAddress(tokenName).performClick() }
} }
step("Assert 'Swap' button has badge") {
onTokenDetailsScreen { swapButton().assertHasBadge() }
}
step("Click on 'Swap' button on 'Token details' screen") { step("Click on 'Swap' button on 'Token details' screen") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Check stories changes") { step("Check stories changes") {
checkStoriesChanges() checkStoriesChanges()
@ -369,11 +246,11 @@ class SwapStoriesTest : BaseTestCase() {
step("Synchronize addresses") { step("Synchronize addresses") {
synchronizeAddresses() synchronizeAddresses()
} }
step("Open 'Markets' token details screen for token '$tokenName'") { step("Open 'Token details' from 'Markets' screen for token '$tokenName'") {
openMarketTokenDetailsScreen(blockchainName = tokenName, tokenName = tokenName) openTokenDetailsFromMarketsScreen(blockchainName = tokenName, tokenName = tokenName)
} }
step("Click on 'Swap' button on 'Markets' token details screen") { step("Click on 'Swap' button on 'Markets' token details screen") {
onMarketsTokenDetailsScreen { swapPortfolioQuickActionButton.performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Check stories changes") { step("Check stories changes") {
checkStoriesChanges() checkStoriesChanges()
@ -388,7 +265,7 @@ class SwapStoriesTest : BaseTestCase() {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
} }
step("Open 'Swap' screen without stories") { step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.MarketsTokenDetails, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
} }
} }
} }
@ -433,6 +310,17 @@ class SwapStoriesTest : BaseTestCase() {
step("Click on 'Close' button") { step("Click on 'Close' button") {
onSwapTokenScreen { closeButton.performClick() } onSwapTokenScreen { closeButton.performClick() }
} }
step("Long click on token with name: '$tokenName' again to reopen actions menu") {
waitForIdle()
onMainScreen {
tokenWithTitleAndAddress(tokenName).performTouchInput {
longClick(
position = center,
durationMillis = 1000L,
)
}
}
}
step("Open 'Swap' screen without stories") { step("Open 'Swap' screen without stories") {
openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false) openSwapScreen(from = SwapEntryPoint.TokenActionsBottomSheet, storiesExist = false)
} }

View file

@ -18,6 +18,7 @@ import com.tangem.screens.*
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.AllureId
import io.qameta.allure.kotlin.junit4.DisplayName import io.qameta.allure.kotlin.junit4.DisplayName
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@HiltAndroidTest @HiltAndroidTest
@ -50,7 +51,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -147,7 +148,7 @@ class SwapTokenScreenTest : BaseTestCase() {
disableMobileData() disableMobileData()
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -201,7 +202,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -304,7 +305,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onTokenDetailsScreen { title.assertIsDisplayed() } onTokenDetailsScreen { title.assertIsDisplayed() }
} }
step("Click on 'Swap' button") { step("Click on 'Swap' button") {
onTokenDetailsScreen { swapButton().performClick() } onTokenDetailsScreen { swapButton.performClick() }
} }
step("Close 'Stories' screen") { step("Close 'Stories' screen") {
onSwapStoriesScreen { closeButton.clickWithAssertion() } onSwapStoriesScreen { closeButton.clickWithAssertion() }
@ -510,7 +511,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(polygon).clickWithAssertion() }
} }
step("Assert 'Swap' button is not dimmed. Swap available") { step("Assert 'Swap' button is not dimmed. Swap available") {
onTokenDetailsScreen { swapButton().assertIsDimmed(false) } onTokenDetailsScreen { swapButton.assertIsEnabled() }
} }
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -519,7 +520,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(bitcoin).clickWithAssertion() }
} }
step("Assert 'Swap' button is dimmed") { step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
step("Press 'Back' button") { step("Press 'Back' button") {
device.uiDevice.pressBack() device.uiDevice.pressBack()
@ -528,7 +529,7 @@ class SwapTokenScreenTest : BaseTestCase() {
onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() } onMainScreen { tokenWithTitleAndAddress(salam).clickWithAssertion() }
} }
step("Assert 'Swap' button is dimmed") { step("Assert 'Swap' button is dimmed") {
onTokenDetailsScreen { swapButton().assertIsDimmed(true) } onTokenDetailsScreen { swapButton.assertIsNotEnabled() }
} }
} }
} }
@ -545,11 +546,11 @@ class SwapTokenScreenTest : BaseTestCase() {
val inputAmount = "0.99" val inputAmount = "0.99"
val market = "Market" val market = "Market"
val fast = "Fast" val fast = "Fast"
val marketFeeAmount = "$1.12"
val fastFeeAmount = "$1.43"
setupHooks().run { setupHooks().run {
var marketFee = 0.0
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
} }
@ -575,19 +576,28 @@ class SwapTokenScreenTest : BaseTestCase() {
textInput.performTextReplacement(inputAmount) textInput.performTextReplacement(inputAmount)
} }
} }
step("Select '$market' fee type") { step("Select '$market' fee type and capture its amount") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeType(FeeType.Market, selectedFeeAmount = marketFeeAmount) marketFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Market))
} }
} }
step("Select '$fast' fee type") { step("Select '$fast' fee type and assert it exceeds the '$market' fee") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeType(FeeType.Fast, selectedFeeAmount = fastFeeAmount) val fastFee = parseFeeAmount(selectFeeTypeAndReadFee(FeeType.Fast))
assertTrue(
"Expected '$fast' fee ($fastFee) to be greater than '$market' fee ($marketFee)",
fastFee > marketFee,
)
} }
} }
} }
} }
private fun parseFeeAmount(raw: String): Double = raw
.replace(Regex("[^0-9.]"), "")
.toDoubleOrNull()
?: error("Could not parse a numeric fee amount from '$raw'")
@AllureId("8536") @AllureId("8536")
@DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)") @DisplayName("Swap: check switch fee type (unable to cover 'Market' and 'Fast' fee)")
@Test @Test
@ -600,18 +610,24 @@ class SwapTokenScreenTest : BaseTestCase() {
val feeAmount = "$" val feeAmount = "$"
val scenarioName = "eth_network_balance" val scenarioName = "eth_network_balance"
val scenarioState = "LessThanDollar" val scenarioState = "LessThanDollar"
val pairsScenarioName = "ethereum_from_pairs"
val pairsScenarioState = "DexProvider"
val networkName = "Ethereum" val networkName = "Ethereum"
val currencySymbol = "ETH" val currencySymbol = "ETH"
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
resetWireMockScenarioState(scenarioName) resetWireMockScenarioState(scenarioName)
resetWireMockScenarioState(pairsScenarioName)
} }
).run { ).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
} }
step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") {
setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState)
}
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
@ -679,6 +695,8 @@ class SwapTokenScreenTest : BaseTestCase() {
val marketFeeAmount = "$1." val marketFeeAmount = "$1."
val scenarioName = "eth_fee_history" val scenarioName = "eth_fee_history"
val scenarioState = "UnableToCoverFastFee" val scenarioState = "UnableToCoverFastFee"
val pairsScenarioName = "ethereum_from_pairs"
val pairsScenarioState = "DexProvider"
val networkName = "Ethereum" val networkName = "Ethereum"
val currencySymbol = "ETH" val currencySymbol = "ETH"
@ -686,12 +704,16 @@ class SwapTokenScreenTest : BaseTestCase() {
setupHooks( setupHooks(
additionalAfterSection = { additionalAfterSection = {
resetWireMockScenarioState(scenarioName) resetWireMockScenarioState(scenarioName)
resetWireMockScenarioState(pairsScenarioName)
} }
).run { ).run {
step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") { step("Set WireMock scenario: '$scenarioName' to state: '$scenarioState'") {
setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState) setWireMockScenarioState(scenarioName = scenarioName, state = scenarioState)
} }
step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") {
setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState)
}
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()
@ -728,7 +750,7 @@ class SwapTokenScreenTest : BaseTestCase() {
} }
step("Select '$fastFeeType' fee type") { step("Select '$fastFeeType' fee type") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeTypeWithGasless(feeType = FeeType.Fast, fastFeeAmount) selectFeeType(feeType = FeeType.Fast, selectedFeeAmount = fastFeeAmount)
} }
} }
step("Assert fee amount is equal to '$fastFeeType' fee:'$fastFeeAmount'") { step("Assert fee amount is equal to '$fastFeeType' fee:'$fastFeeAmount'") {
@ -744,7 +766,7 @@ class SwapTokenScreenTest : BaseTestCase() {
} }
step("Select '$marketFeeType' fee type") { step("Select '$marketFeeType' fee type") {
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
selectFeeTypeWithGasless(feeType = FeeType.Market, marketFeeAmount) selectFeeType(feeType = FeeType.Market, selectedFeeAmount = marketFeeAmount)
} }
} }
step("Assert 'Swap' button is enabled") { step("Assert 'Swap' button is enabled") {

View file

@ -86,6 +86,8 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
val tokensScenarioState = "SolanaUSDC" val tokensScenarioState = "SolanaUSDC"
val balanceScenarioName = "solana_balance" val balanceScenarioName = "solana_balance"
val balanceScenarioState = "Empty" val balanceScenarioState = "Empty"
val pairsScenarioName = "solana_from_pairs"
val pairsScenarioState = "DexProvider"
val networkName = "Solana" val networkName = "Solana"
val currencySymbol = "SOL" val currencySymbol = "SOL"
@ -94,6 +96,7 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
resetWireMockScenarioState(USER_TOKENS_API_SCENARIO) resetWireMockScenarioState(USER_TOKENS_API_SCENARIO)
resetWireMockScenarioState(QUOTES_API_SCENARIO) resetWireMockScenarioState(QUOTES_API_SCENARIO)
resetWireMockScenarioState(balanceScenarioName) resetWireMockScenarioState(balanceScenarioName)
resetWireMockScenarioState(pairsScenarioName)
} }
).run { ).run {
@ -106,6 +109,9 @@ class SwapTokenScreenWarningsTest : BaseTestCase() {
step("Set WireMock scenario: '$balanceScenarioName' to state: $balanceScenarioState") { step("Set WireMock scenario: '$balanceScenarioName' to state: $balanceScenarioState") {
setWireMockScenarioState(scenarioName = balanceScenarioName, state = balanceScenarioState) setWireMockScenarioState(scenarioName = balanceScenarioName, state = balanceScenarioState)
} }
step("Set WireMock scenario: '$pairsScenarioName' to state: $pairsScenarioState") {
setWireMockScenarioState(scenarioName = pairsScenarioName, state = pairsScenarioState)
}
step("Open 'Main Screen'") { step("Open 'Main Screen'") {
openMainScreen() openMainScreen()

View file

@ -3,12 +3,13 @@ package com.tangem.tests.tangempay
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.tangem.common.BaseTestCase import com.tangem.common.BaseTestCase
import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO import com.tangem.common.constants.TestConstants.TANGEM_PAY_ELIGIBILITY_SCENARIO
import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_LONG
import com.tangem.common.extensions.assertTextContainsSafe import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.extensions.extractText import com.tangem.common.extensions.extractText
import com.tangem.common.extensions.pullToRefresh
import com.tangem.common.utils.assertClipboardTextEquals import com.tangem.common.utils.assertClipboardTextEquals
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.scenarios.* import com.tangem.scenarios.*
import com.tangem.screens.tangempay.* import com.tangem.screens.tangempay.*
@ -23,7 +24,7 @@ class TangemPayTest : BaseTestCase() {
@AllureId("4549") @AllureId("4549")
@DisplayName("Tangem Pay: change PIN code from card details") @DisplayName("Tangem Pay: change PIN code from card details")
@Test @Test
fun changePin_SetsNewPinCode_FromCardDetails() { fun changePinSetsNewPinCodeFromCardDetailsTest() {
val newPin = "5217" val newPin = "5217"
val pinSetupScenario = "tangem_pay_pin_setup" val pinSetupScenario = "tangem_pay_pin_setup"
val pinNotSetState = "PinNotSet" val pinNotSetState = "PinNotSet"
@ -31,6 +32,7 @@ class TangemPayTest : BaseTestCase() {
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(pinSetupScenario, pinNotSetState) setWireMockScenarioState(pinSetupScenario, pinNotSetState)
}, },
@ -52,11 +54,10 @@ class TangemPayTest : BaseTestCase() {
step("Enter PIN '$newPin'") { step("Enter PIN '$newPin'") {
onTangemPayChangePinScreen { inputField.performTextInput(newPin) } onTangemPayChangePinScreen { inputField.performTextInput(newPin) }
} }
step("Click on 'Submit' button") { step("Assert success screen title is displayed") {
onTangemPayChangePinScreen { submitButton.performClick() } flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
} onTangemPayChangePinScreen { successTitle.assertIsDisplayed() }
step("Assert success screen is displayed") { }
onTangemPayChangePinScreen { successTitle.assertIsDisplayed() }
} }
step("Click on 'Done' button") { step("Click on 'Done' button") {
onTangemPayChangePinScreen { doneButton.clickWithAssertion() } onTangemPayChangePinScreen { doneButton.clickWithAssertion() }
@ -67,7 +68,7 @@ class TangemPayTest : BaseTestCase() {
@AllureId("4969") @AllureId("4969")
@DisplayName("Tangem Pay: balance updates after transaction on payment account screen") @DisplayName("Tangem Pay: balance updates after transaction on payment account screen")
@Test @Test
fun balanceUpdatesAfterTransaction_OnPaymentAccountScreen() { fun balanceUpdatesAfterTransactionOnPaymentAccountScreenTest() {
val balanceScenario = "tangem_pay_balance_update" val balanceScenario = "tangem_pay_balance_update"
val initialState = "InitialBalance" val initialState = "InitialBalance"
val afterTransactionState = "AfterTransaction" val afterTransactionState = "AfterTransaction"
@ -75,6 +76,7 @@ class TangemPayTest : BaseTestCase() {
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(balanceScenario, initialState) setWireMockScenarioState(balanceScenario, initialState)
}, },
@ -90,7 +92,7 @@ class TangemPayTest : BaseTestCase() {
step("Switch WireMock scenario '$balanceScenario' to '$afterTransactionState'") { step("Switch WireMock scenario '$balanceScenario' to '$afterTransactionState'") {
setWireMockScenarioState(balanceScenario, afterTransactionState) setWireMockScenarioState(balanceScenario, afterTransactionState)
} }
step("Pull to refresh") { pullToRefresh() } step("Pull to refresh") { pullToRefreshTangemPay() }
step("Assert updated balance contains '9'") { step("Assert updated balance contains '9'") {
onTangemPayMainScreen { balance.assertTextContainsSafe("9", substring = true) } onTangemPayMainScreen { balance.assertTextContainsSafe("9", substring = true) }
} }
@ -100,7 +102,7 @@ class TangemPayTest : BaseTestCase() {
@AllureId("4970") @AllureId("4970")
@DisplayName("Tangem Pay: new transaction appears after mocked charge") @DisplayName("Tangem Pay: new transaction appears after mocked charge")
@Test @Test
fun transactionList_NewTransactionAppears_AfterMockedCharge() { fun transactionListNewTransactionAppearsAfterMockedChargeTest() {
val historyScenario = "tangem_pay_transaction_history" val historyScenario = "tangem_pay_transaction_history"
val initialState = "InitialEmpty" val initialState = "InitialEmpty"
val afterTransactionState = "AfterTransaction" val afterTransactionState = "AfterTransaction"
@ -109,6 +111,7 @@ class TangemPayTest : BaseTestCase() {
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(historyScenario, initialState) setWireMockScenarioState(historyScenario, initialState)
}, },
@ -126,7 +129,7 @@ class TangemPayTest : BaseTestCase() {
step("Switch WireMock scenario '$historyScenario' to '$afterTransactionState'") { step("Switch WireMock scenario '$historyScenario' to '$afterTransactionState'") {
setWireMockScenarioState(historyScenario, afterTransactionState) setWireMockScenarioState(historyScenario, afterTransactionState)
} }
step("Pull to refresh") { pullToRefresh() } step("Pull to refresh") { pullToRefreshTangemPay() }
step("Assert transaction from '$merchantName' is displayed") { step("Assert transaction from '$merchantName' is displayed") {
onTangemPayMainScreen { onTangemPayMainScreen {
transactionRowWithText(merchantName).assertIsDisplayed() transactionRowWithText(merchantName).assertIsDisplayed()
@ -138,12 +141,13 @@ class TangemPayTest : BaseTestCase() {
@AllureId("4974") @AllureId("4974")
@DisplayName("Tangem Pay: reveal and copy card number, expiration and CVC") @DisplayName("Tangem Pay: reveal and copy card number, expiration and CVC")
@Test @Test
fun revealAndCopyCardDetails_NumberExpirationCVC() { fun revealAndCopyCardDetailsNumberExpirationCVCTest() {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
val eligibilityState = "PaeraCustomer" val eligibilityState = "PaeraCustomer"
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
}, },
additionalAfterSection = { additionalAfterSection = {
@ -152,9 +156,11 @@ class TangemPayTest : BaseTestCase() {
).run { ).run {
openTangemPay() openTangemPay()
step("Click on card button") { step("Click on card button") {
waitForIdle()
onTangemPayMainScreen { cardButton.clickWithAssertion() } onTangemPayMainScreen { cardButton.clickWithAssertion() }
} }
step("Click on 'Show details' button") { step("Click on 'Show details' button") {
waitForIdle()
onTangemPayCardPageScreen { showDetailsButton.clickWithAssertion() } onTangemPayCardPageScreen { showDetailsButton.clickWithAssertion() }
} }
step("Assert number, expiration and CVC values are visible") { step("Assert number, expiration and CVC values are visible") {
@ -200,13 +206,14 @@ class TangemPayTest : BaseTestCase() {
@AllureId("4971") @AllureId("4971")
@DisplayName("Tangem Pay: freeze card via confirmation sheet") @DisplayName("Tangem Pay: freeze card via confirmation sheet")
@Test @Test
fun freezeUnfreezeCard_TogglesCardState() { fun freezeUnfreezeCardTogglesCardStateTest() {
val freezeScenario = "tangem_pay_card_freeze" val freezeScenario = "tangem_pay_card_freeze"
val startedState = "Started" val startedState = "Started"
val eligibilityState = "PaeraCustomer" val eligibilityState = "PaeraCustomer"
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(freezeScenario, startedState) setWireMockScenarioState(freezeScenario, startedState)
}, },

View file

@ -9,6 +9,7 @@ import com.tangem.common.constants.TestConstants.WAIT_UNTIL_TIMEOUT_VERY_LONG
import com.tangem.common.extensions.assertTextContainsSafe import com.tangem.common.extensions.assertTextContainsSafe
import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.extensions.clickWithAssertion
import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.common.utils.resetWireMockScenarioState
import com.tangem.common.utils.resetWireMockScenarios
import com.tangem.common.utils.setWireMockScenarioState import com.tangem.common.utils.setWireMockScenarioState
import com.tangem.core.res.R as CoreResR import com.tangem.core.res.R as CoreResR
import com.tangem.scenarios.* import com.tangem.scenarios.*
@ -26,7 +27,7 @@ class TangemPayTopUpTest : BaseTestCase() {
@AllureId("4973") @AllureId("4973")
@DisplayName("Tangem Pay: top up swaps Bitcoin to USDC and appends deposit to history") @DisplayName("Tangem Pay: top up swaps Bitcoin to USDC and appends deposit to history")
@Test @Test
fun topUpFromTangemPay_SwapsBitcoinToUSDC_AppendsDepositToHistory() { fun topUpFromTangemPaySwapsBitcoinToUSDCAppendsDepositToHistoryTest() {
val bitcoinScenario = "bitcoin_utxo" val bitcoinScenario = "bitcoin_utxo"
val expressAssetsScenario = "express_api_assets" val expressAssetsScenario = "express_api_assets"
val balanceScenario = "tangem_pay_balance_update" val balanceScenario = "tangem_pay_balance_update"
@ -43,6 +44,7 @@ class TangemPayTopUpTest : BaseTestCase() {
setupHooks( setupHooks(
additionalBeforeSection = { additionalBeforeSection = {
resetWireMockScenarios()
setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState) setWireMockScenarioState(TANGEM_PAY_ELIGIBILITY_SCENARIO, eligibilityState)
setWireMockScenarioState(bitcoinScenario, bitcoinBalanceState) setWireMockScenarioState(bitcoinScenario, bitcoinBalanceState)
setWireMockScenarioState(expressAssetsScenario, expressAssetsState) setWireMockScenarioState(expressAssetsScenario, expressAssetsState)
@ -62,6 +64,7 @@ class TangemPayTopUpTest : BaseTestCase() {
onTangemPayMainScreen { balance.assertTextContainsSafe("10", substring = true) } onTangemPayMainScreen { balance.assertTextContainsSafe("10", substring = true) }
} }
step("Click on 'Top Up' action chip") { step("Click on 'Top Up' action chip") {
waitForIdle()
onTangemPayMainScreen { topUpButton.clickWithAssertion() } onTangemPayMainScreen { topUpButton.clickWithAssertion() }
} }
step("Assert 'Add Funds' sheet is displayed") { step("Assert 'Add Funds' sheet is displayed") {

File diff suppressed because it is too large Load diff

View file

@ -210,6 +210,17 @@
android:scheme="tangem" /> android:scheme="tangem" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="survey"
android:scheme="tangem" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@ -402,6 +413,14 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" /> <action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter> </intent-filter>
</service> </service>
<!-- SurveySparrow SDK exports this activity (exported="true") in its own manifest. -->
<!-- We launch it only via an explicit in-app intent, so force it non-exported to -->
<!-- prevent any other installed app from loading arbitrary web content into it. -->
<activity
android:name="com.surveysparrow.ss_android_sdk.SsSurveyActivity"
android:exported="false"
tools:replace="android:exported" />
</application> </application>
<queries> <queries>
<!-- Needed from Android 11 to open Google Wallet for payment with Visa card --> <!-- Needed from Android 11 to open Google Wallet for payment with Visa card -->

@ -1 +1 @@
Subproject commit 97ff5929f9ff4da53190eb10e94c45ac3bd05093 Subproject commit f983c6defd0b2240eb6f134aefe30f7e93696795

View file

@ -5,9 +5,12 @@ import com.tangem.core.abtests.manager.ABTestsManager
import com.tangem.core.analytics.filter.OneTimeEventFilter import com.tangem.core.analytics.filter.OneTimeEventFilter
import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor import com.tangem.core.analytics.paramsinterceptor.SendTransactionSignerInfoInterceptor
import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager import com.tangem.core.configtoggle.blockchain.ExcludedBlockchainsManager
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.core.configtoggle.feature.FeatureTogglesManager import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.datasource.api.common.config.managers.ApiConfigsManager import com.tangem.datasource.api.common.config.managers.ApiConfigsManager
import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase import com.tangem.domain.walletconnect.usecase.initialize.WcInitializeUseCase
import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.repository.WalletsRepository
@ -49,4 +52,10 @@ interface ApplicationEntryPoint {
fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory fun getAppsFlyerClientFactory(): AppsFlyerClient.Factory
fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor fun getSendTransactionSignerInfoInterceptor(): SendTransactionSignerInfoInterceptor
fun getDeviceKeyManager(): DeviceKeyManager
fun getDeviceRegistrar(): DeviceRegistrar
fun getAuthFeatureToggles(): AuthFeatureToggles
} }

View file

@ -177,12 +177,7 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
TangemLogger.i("Splash screen installed") TangemLogger.i("Splash screen installed")
enableEdgeToEdge( applyEdgeToEdge()
navigationBarStyle = SystemBarStyle.auto(
Color.Transparent.toArgb(),
Color.Transparent.toArgb(),
),
)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -200,6 +195,10 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
} }
splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown } splashScreen.setKeepOnScreenCondition { viewModel.isSplashScreenShown }
splashScreen.setOnExitAnimationListener { provider ->
provider.remove()
applyEdgeToEdge()
}
installActivityDependencies() installActivityDependencies()
observeAppThemeModeUpdates() observeAppThemeModeUpdates()
@ -223,6 +222,15 @@ class MainActivity : AppCompatActivity(), ActivityResultCallbackHolder {
} }
} }
private fun applyEdgeToEdge() {
enableEdgeToEdge(
navigationBarStyle = SystemBarStyle.auto(
Color.Transparent.toArgb(),
Color.Transparent.toArgb(),
),
)
}
private fun setRootContent() { private fun setRootContent() {
// for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy // for now activity is singleTop and after going to ChromeCustomTab it calls onCreate but onDestroy
// doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it // doesn't calls. It lead to issue that decompose nav stack is not saved in bundle and to restore it

View file

@ -21,6 +21,9 @@ import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.domain.apptheme.GetAppThemeModeUseCase import com.tangem.domain.apptheme.GetAppThemeModeUseCase
import com.tangem.domain.common.LogConfig import com.tangem.domain.common.LogConfig
import com.tangem.domain.wallets.repository.WalletsRepository import com.tangem.domain.wallets.repository.WalletsRepository
import com.tangem.lib.auth.AuthFeatureToggles
import com.tangem.lib.auth.devicekey.DeviceKeyManager
import com.tangem.lib.auth.session.DeviceRegistrar
import com.tangem.tap.common.analytics.AnalyticsFactory import com.tangem.tap.common.analytics.AnalyticsFactory
import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder import com.tangem.tap.common.analytics.api.AnalyticsHandlerBuilder
import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler import com.tangem.tap.common.analytics.handlers.BlockchainExceptionHandler
@ -92,6 +95,15 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
private val sendTransactionSignerInfoInterceptor private val sendTransactionSignerInfoInterceptor
get() = entryPoint.getSendTransactionSignerInfoInterceptor() get() = entryPoint.getSendTransactionSignerInfoInterceptor()
private val deviceKeyManager: DeviceKeyManager
get() = entryPoint.getDeviceKeyManager()
private val deviceRegistrar: DeviceRegistrar
get() = entryPoint.getDeviceRegistrar()
private val authFeatureToggles: AuthFeatureToggles
get() = entryPoint.getAuthFeatureToggles()
// endregion // endregion
private val appScope = MainScope() private val appScope = MainScope()
@ -132,6 +144,16 @@ open class TangemApplication : Application(), ImageLoaderFactory, Configuration.
} }
fun init() { fun init() {
if (authFeatureToggles.isBackendAuthenticationEnabled) {
appScope.launch {
// Order matters: registration reads the device public key, so it must wait for
// generation to complete. Running them concurrently on first launch would race —
// register() would see `DeviceKeyUnavailable` and defer to the next app launch.
deviceKeyManager.generateIfMissing()
deviceRegistrar.register()
.onLeft { error -> TangemLogger.w("Device registration deferred: $error") }
}
}
walletsRepository = entryPoint.getWalletsRepository() walletsRepository = entryPoint.getWalletsRepository()
apiConfigsManager.initialize() apiConfigsManager.initialize()

Some files were not shown because too many files have changed in this diff Show more