Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-08 12:19:32 +03:00
parent 16fb5487ee
commit c16e04089d
10 changed files with 155 additions and 80 deletions

View file

@ -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]}<redacted>" }
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 <T> 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
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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(

View file

@ -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() }

View file

@ -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() }
}
}

View file

@ -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) {

View file

@ -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(

View file

@ -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"