Updated on 2026-08-14
This commit is contained in:
parent
16fb5487ee
commit
c16e04089d
10 changed files with 155 additions and 80 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue