diff --git a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt index 675839e656..678de72af2 100644 --- a/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt +++ b/app/src/androidTest/kotlin/com/tangem/common/utils/NetworkUtils.kt @@ -1,10 +1,16 @@ package com.tangem.common.utils +import okhttp3.Call +import okhttp3.EventListener import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import org.json.JSONObject import com.tangem.utils.logging.TangemLogger +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy import java.util.concurrent.TimeUnit // WC URIs embed a session symKey that lets anyone join/hijack the session — strip it before logging. @@ -13,6 +19,79 @@ private val WC_SECRET_REGEX = Regex("(symKey(?:=|%3D))[^&\\s\"']+", RegexOption. private fun redactWcSecrets(text: String): String = WC_SECRET_REGEX.replace(text) { "${it.groupValues[1]}" } +private const val DEFAULT_MAX_ATTEMPTS = 4 +private const val INITIAL_BACKOFF_MS = 500L + +/** + * Runs [block] with exponential backoff. Retries ONLY when [block] throws (transient failure: + * network error, non-2xx, malformed body). A `null` return is treated as terminal (e.g. "200 but no + * data for this key") and is NOT retried. Returns the block result, or null if all attempts failed. + */ +private fun retryWithBackoff( + maxAttempts: Int = DEFAULT_MAX_ATTEMPTS, + initialDelayMs: Long = INITIAL_BACKOFF_MS, + block: (attempt: Int) -> T, +): T? { + var delayMs = initialDelayMs + var lastError: Throwable? = null + repeat(maxAttempts) { i -> + val attempt = i + 1 + try { + return block(attempt) + } catch (e: Exception) { + lastError = e + TangemLogger.w("Attempt $attempt/$maxAttempts failed: ${e.message}") + if (attempt < maxAttempts) { + try { + Thread.sleep(delayMs) + } catch (ie: InterruptedException) { + Thread.currentThread().interrupt() + TangemLogger.w("Retry backoff sleep interrupted; aborting retries") + return null + } + delayMs *= 2 + } + } + } + TangemLogger.e("All $maxAttempts attempts failed", lastError) + return null +} + +/** + * Logs the actual connected endpoint (IPv4/IPv6) and whether a proxy is in the path. Lets CI logs + * distinguish "went out via the wrong egress / IPv6 / through a local proxy" from other failures. + */ +private val diagnosticEventListener = object : EventListener() { + override fun connectStart(call: Call, inetSocketAddress: InetSocketAddress, proxy: Proxy) { + TangemLogger.i("Connecting to ${inetSocketAddress.address?.hostAddress} (proxy=$proxy)") + } +} + +private fun diagnosticClient(connectSec: Long, readSec: Long, callSec: Long): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(connectSec, TimeUnit.SECONDS) + .readTimeout(readSec, TimeUnit.SECONDS) + .callTimeout(callSec, TimeUnit.SECONDS) + // Don't follow redirects: a Cloudflare Access 302 must stay visible (its `location` points at + // cloudflareaccess.com) so logHttpFailure can flag "egress IP not allow-listed". /health and + // /addresses have no legitimate redirects. + .followRedirects(false) + .followSslRedirects(false) + .eventListener(diagnosticEventListener) + .build() + +/** + * Logs enough to classify a failed response at a glance: `cf-ray`/`location`/`server` reveal a + * Cloudflare Access 302 (egress IP not allow-listed) vs an origin 5xx vs anything else. + */ +private fun logHttpFailure(tag: String, response: Response, body: String) { + TangemLogger.e( + "$tag failed: code=${response.code} cf-ray=${response.header("cf-ray") ?: "-"} " + + "server=${response.header("server") ?: "-"} location=${response.header("location") ?: "-"} " + + "body=${body.take(200)}", + ) +} + /** * Requests a WalletConnect URI from the qa-tools service. * @@ -142,50 +221,40 @@ fun getAddressesFromApi( ): String? { TangemLogger.i("Getting addresses for seed key: $seedKey") - val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .callTimeout(90, TimeUnit.SECONDS) - .build() - + val client = diagnosticClient(connectSec = 30, readSec = 60, callSec = 90) val request = Request.Builder() .url("$baseUrl/addresses") .get() .build() - return try { + return retryWithBackoff { attempt -> + TangemLogger.i("Getting addresses for '$seedKey', attempt $attempt") client.newCall(request).execute().use { response -> - TangemLogger.i("Response code: ${response.code}") + if (!response.isSuccessful) { + // Transient (network/Access 302/5xx) — throw so retryWithBackoff retries. + logHttpFailure("getAddressesFromApi", response, response.body?.string() ?: "") + throw IOException("getAddressesFromApi: HTTP ${response.code}") + } - if (response.isSuccessful) { - val body = response.body?.string() ?: "" + val body = response.body?.string() ?: "" + val contentType = response.header("Content-Type") ?: "" + if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) { + logHttpFailure("getAddressesFromApi (not JSON)", response, body) + throw IOException("getAddressesFromApi: unexpected non-JSON response") + } - val contentType = response.header("Content-Type") ?: "" - if (!contentType.contains("application/json") && !body.trimStart().startsWith("{")) { - TangemLogger.e("Unexpected response (not JSON), Content-Type: $contentType, body: $body") - return null - } - - val jsonObject = JSONObject(body) - val data = jsonObject.optJSONObject("data") ?: jsonObject - val seedData = data.optJSONArray(seedKey) - - if (seedData != null) { - TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries") - seedData.toString() - } else { - TangemLogger.e("No data found for seed key: $seedKey") - null - } + val jsonObject = JSONObject(body) + val data = jsonObject.optJSONObject("data") ?: jsonObject + val seedData = data.optJSONArray(seedKey) + if (seedData != null) { + TangemLogger.i("Got addresses for $seedKey: ${seedData.length()} entries") + seedData.toString() } else { - val errorBody = response.body?.string() ?: "No error body" - TangemLogger.e("Request failed: ${response.code}, body: $errorBody") + // Terminal: server responded fine but has no data for this key — retrying won't help. + TangemLogger.e("No data found for seed key: $seedKey") null } } - } catch (e: Exception) { - TangemLogger.e("Error getting addresses", e) - null } } @@ -194,43 +263,31 @@ fun checkServiceHealth( ): String? { TangemLogger.i("Checking service health") - val client = OkHttpClient() + val client = diagnosticClient(connectSec = 15, readSec = 30, callSec = 45) val request = Request.Builder() .url("$baseUrl/health") .get() .build() - return try { + return retryWithBackoff { attempt -> + TangemLogger.i("Checking service health, attempt $attempt") client.newCall(request).execute().use { response -> - TangemLogger.i("Response code: ${response.code}") + if (!response.isSuccessful) { + // Transient (network/Access 302/5xx) — throw so retryWithBackoff retries. + logHttpFailure("checkServiceHealth", response, response.body?.string() ?: "") + throw IOException("checkServiceHealth: HTTP ${response.code}") + } - if (response.isSuccessful) { - val body = response.body?.string() ?: "" - TangemLogger.i("Response body: $body") - - if (body.isEmpty()) { - TangemLogger.e("Response body is empty") - return null - } - - val jsonObject = JSONObject(body) - val status = jsonObject.optString("status", "") - - if (status.isNotEmpty()) { - TangemLogger.i("Got status successfully: $status") - status - } else { - TangemLogger.e("Status field is missing or empty") - null - } + val body = response.body?.string() ?: "" + val status = if (body.isEmpty()) "" else JSONObject(body).optString("status", "") + if (status.isNotEmpty()) { + TangemLogger.i("Got status successfully: $status") + status } else { - val errorBody = response.body?.string() ?: "No error body" - TangemLogger.e("Request failed: ${response.code}, body: $errorBody") - null + // Empty/malformed body from a 2xx — treat as transient and retry. + logHttpFailure("checkServiceHealth (empty status)", response, body) + throw IOException("checkServiceHealth: missing 'status' field") } } - } catch (e: Exception) { - TangemLogger.e("Error checking health", e) - null } } \ No newline at end of file diff --git a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt index 7e86edb967..faabacd56f 100644 --- a/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt +++ b/app/src/androidTest/kotlin/com/tangem/scenarios/GaslessScenarios.kt @@ -42,7 +42,14 @@ fun BaseTestCase.enterAmountAndOpenSendConfirm(amount: String, 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) { +fun BaseTestCase.selectStablecoinAsFeeToken( + coinName: String, + tokenName: String, + // Positive flows wait until 'Apply' is enabled (fee is computed & payable). Negative flows + // (insufficient balance) must NOT wait for that — 'Apply' stays disabled — so pass false and let + // the caller assert the disabled/error state itself. + expectApplyEnabled: Boolean = true, +) { step("Click on 'Network fee' block") { onSendConfirmScreen { feeSelectorBlock.assertIsDisplayed() @@ -57,13 +64,13 @@ fun BaseTestCase.selectStablecoinAsFeeToken(coinName: String, tokenName: String) step("Select '$tokenName' as the fee-paying token") { onSendFeeSelectorBottomSheet { feeTokenItem(tokenName).performClick() } } - step("Wait until the '$tokenName' fee is loaded and 'Apply' is enabled") { + step("Wait until the '$tokenName' fee is loaded") { composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) { runCatching { onSendFeeSelectorBottomSheet { networkFeeTitle.assertIsDisplayed() feeTokenItem(tokenName).assertIsDisplayed() - applyButton.assertIsEnabled() + if (expectApplyEnabled) applyButton.assertIsEnabled() } }.isSuccess } diff --git a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt index c6b343f37b..7bf313c7df 100644 --- a/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt +++ b/app/src/androidTest/kotlin/com/tangem/screens/MainScreenPageObject.kt @@ -139,12 +139,13 @@ class MainScreenPageObject(private val semanticsProvider: SemanticsNodeInteracti } val walletImportedBanner: KNode = child { - hasTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER) + hasTestTag(NotificationTestTags.TITLE) + hasText(getResourceString(CoreResR.string.initial_wallet_sync_banner_title)) useUnmergedTree = true } val walletImportedBannerCheckHereButton: KNode = child { - hasAnyAncestor(withTestTag(WalletNotificationTestTags.ASSETS_DISCOVERY_BANNER)) + hasAnyAncestor(withTestTag(NotificationTestTags.CONTAINER)) hasText(getResourceString(CoreResR.string.main_manage_tokens)) useUnmergedTree = true } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt index 105eac0ecc..1c1673a5c8 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/SendTest.kt @@ -23,9 +23,9 @@ class SendTest : BaseTestCase() { val currencyName = "USDC" val feeCurrencyName = "Solana" val feeCurrencySymbol = "SOL" - val balanceScenarioName = "solana_balance" + val balanceScenarioName = "solana_get_account_info_recipient" val tokensScenarioName = "user_tokens_api" - val balanceState = "Empty" + val balanceState = "ZeroBalance" val tokensState = "SolanaUSDC" setupHooks( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt index 2cf1653be7..6303345b96 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/actionButtons/TokenDetailsScreenActionButtonsTest.kt @@ -20,6 +20,7 @@ import com.tangem.screens.onTransferBottomSheet import dagger.hilt.android.testing.HiltAndroidTest import io.qameta.allure.kotlin.AllureId import io.qameta.allure.kotlin.junit4.DisplayName +import org.junit.Ignore import org.junit.Test @HiltAndroidTest @@ -28,6 +29,7 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { @AllureId("594") @DisplayName("Action buttons (token details screen): validate UI") @Test + @Ignore("[REDACTED_JIRA]") fun actionButtonsValidateUiTest() { val tokenTitle = "Bitcoin" @@ -113,8 +115,8 @@ class TokenDetailsScreenActionButtonsTest : BaseTestCase() { step("Assert 'Buy' button in bottom sheet is enabled") { onAddFundsBottomSheet { buyButton.assertIsEnabled() } } - step("Assert 'Swap' button in bottom sheet is disabled") { - onAddFundsBottomSheet { swapButton.assertIsNotEnabled() } + step("Assert 'Swap' button in bottom sheet is not displayed") { + onAddFundsBottomSheet { swapButton.assertIsNotDisplayed() } } step("Assert 'Receive' button in bottom sheet is enabled") { onAddFundsBottomSheet { receiveButton.assertIsEnabled() } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/referral/ReferralTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/referral/ReferralTest.kt index f0d93d7ea1..9caecd4cc5 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/referral/ReferralTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/referral/ReferralTest.kt @@ -45,7 +45,7 @@ class ReferralTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Verify network $tokenNetwork and token $token is not displaying") { + step("Verify network $tokenNetwork and token $token is not displayed") { onMainScreen { assertTokenDoesNotExist(tokenNetwork) assertTokenDoesNotExist(token) @@ -94,10 +94,10 @@ class ReferralTest : BaseTestCase() { step("Synchronize addresses") { synchronizeAddresses() } - step("Verify token $token is not displaying") { + step("Verify token $token is not displayed") { onMainScreen { assertTokenDoesNotExist(token) } } - step("Verify network $tokenNetwork is displaying") { + step("Verify network $tokenNetwork is displayed") { onMainScreen { tokenWithTitleAndAddress(tokenNetwork) } } step("Take participate in Referral program") { @@ -131,7 +131,7 @@ class ReferralTest : BaseTestCase() { step("Open 'Wallet settings' screen") { onDetailsScreen { walletNameButton.clickWithAssertion() } } - step("Verify 'Referral program' button does not displaying") { + step("Verify 'Referral program' button is not displayed") { onWalletSettingsScreen { referralProgramButton.assertDoesNotExist() } } } diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt index c922a7915d..0360bace22 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/sendViaSwap/GaslessSendViaSwapTest.kt @@ -155,7 +155,11 @@ class GaslessSendViaSwapTest : BaseTestCase() { enterSwapAmountAndOpenSendConfirm(amount = tokenAmount, recipientAddress = BITCOIN_RECIPIENT_ADDRESS) } step("Pay the network fee with '$tokenName' via the fee selector") { - selectStablecoinAsFeeToken(coinName = nativeTokenName, tokenName = tokenName) + selectStablecoinAsFeeToken( + coinName = nativeTokenName, + tokenName = tokenName, + expectApplyEnabled = false, + ) } step("Assert 'Not enough funds' error is displayed in the fee selector") { flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { diff --git a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt index 976cfeb85f..9ed4a7569f 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/send/warnings/SolanaWarningsTest.kt @@ -4,9 +4,11 @@ import com.tangem.common.BaseTestCase import com.tangem.common.constants.TestConstants.QUOTES_API_SCENARIO 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.WAIT_UNTIL_TIMEOUT_LONG import com.tangem.common.extensions.clickWithAssertion import com.tangem.common.utils.resetWireMockScenarioState import com.tangem.scenarios.checkSendWarning +import com.tangem.scenarios.openSendConfirmScreenViaNextButton import com.tangem.scenarios.openSendScreen import com.tangem.screens.onSendAddressScreen import com.tangem.screens.onSendScreen @@ -90,8 +92,10 @@ class SolanaWarningsTest : BaseTestCase() { step("Type address in input text field") { onSendAddressScreen { addressTextField.performTextReplacement(SOLANA_RECIPIENT_ADDRESS) } } - step("Click on 'Next' button") { - onSendAddressScreen { nextButton.clickWithAssertion() } + step("Open 'Send confirm' screen via 'Next' button") { + flakySafely(WAIT_UNTIL_TIMEOUT_LONG) { + openSendConfirmScreenViaNextButton() + } } step("Assert 'Invalid amount warning' is not displayed") { checkSendWarning( diff --git a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt index 314b7f2f14..bc6b9e498b 100644 --- a/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt +++ b/app/src/androidTest/kotlin/com/tangem/tests/swap/SwapTokenScreenWarningsTest.kt @@ -84,8 +84,8 @@ class SwapTokenScreenWarningsTest : BaseTestCase() { val tokenTitle = "USDC" val inputAmount = "1000" val tokensScenarioState = "SolanaUSDC" - val balanceScenarioName = "solana_balance" - val balanceScenarioState = "Empty" + val balanceScenarioName = "solana_get_account_info_recipient" + val balanceScenarioState = "ZeroBalance" val pairsScenarioName = "solana_from_pairs" val pairsScenarioState = "DexProvider" val networkName = "Solana" diff --git a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/ui/SendAmountContent.kt b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/ui/SendAmountContent.kt index bcd2557e2f..7d1b2a8b75 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/ui/SendAmountContent.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/subcomponents/amount/ui/SendAmountContent.kt @@ -58,6 +58,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) { modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() + .testTag(SendScreenTestTags.SWAP_TO_ANOTHER_TOKEN_BUTTON) .clickable( indication = null, interactionSource = null, @@ -67,8 +68,7 @@ private fun SendConvertTokenButton(onConvertToAnother: () -> Unit) { Row( modifier = Modifier .padding(12.dp) - .align(Alignment.Center) - .testTag(SendScreenTestTags.SWAP_TO_ANOTHER_TOKEN_BUTTON), + .align(Alignment.Center), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) {