mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Fix flaky UI interactions in donation checkout tests.
This commit is contained in:
+59
-19
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.ComposeTestRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
@@ -32,6 +34,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.pa
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.DonationTransferTestTags
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.testing.actions.scrollToDescendant
|
||||
import org.thoughtcrime.securesms.testing.endpoints.StripeResponses
|
||||
import org.thoughtcrime.securesms.testing.flushUntil
|
||||
@@ -68,7 +72,7 @@ class CheckoutFlowDriver(
|
||||
TestPaymentMethod.GOOGLE_PAY -> scenario.selectGooglePay(composeRule, scheduler, spec.inAppPaymentType)
|
||||
TestPaymentMethod.CREDIT_CARD -> completeCreditCard()
|
||||
TestPaymentMethod.PAYPAL -> completePayPal(scenario, spec.inAppPaymentType)
|
||||
TestPaymentMethod.SEPA_DEBIT -> completeSepa()
|
||||
TestPaymentMethod.SEPA_DEBIT -> completeSepa(spec.inAppPaymentType)
|
||||
TestPaymentMethod.IDEAL -> completeIdeal(spec.inAppPaymentType)
|
||||
}
|
||||
|
||||
@@ -158,7 +162,7 @@ class CheckoutFlowDriver(
|
||||
* it is clicked until the details form appears), fills the bank details, and donates. SEPA requires
|
||||
* a EUR donation currency (see [WorldState.currencyCode]).
|
||||
*/
|
||||
private fun completeSepa() {
|
||||
private fun completeSepa(type: InAppPaymentType) {
|
||||
clickGatewayButton(GatewaySelectorTestTags.SEPA_BUTTON)
|
||||
|
||||
scheduler.flushUntil {
|
||||
@@ -175,7 +179,7 @@ class CheckoutFlowDriver(
|
||||
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_NAME_FIELD, TEST_NAME)
|
||||
typeIntoField(DonationTransferTestTags.SEPA_DETAILS_LIST, DonationTransferTestTags.SEPA_EMAIL_FIELD, TEST_EMAIL)
|
||||
|
||||
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON)
|
||||
clickWhenReady(DonationTransferTestTags.SEPA_DONATE_BUTTON) { paymentLeftCreatedState(type) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,34 +200,59 @@ class CheckoutFlowDriver(
|
||||
typeIntoField(DonationTransferTestTags.IDEAL_DETAILS_LIST, DonationTransferTestTags.IDEAL_EMAIL_FIELD, TEST_EMAIL)
|
||||
}
|
||||
|
||||
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON)
|
||||
clickWhenReady(DonationTransferTestTags.IDEAL_DONATE_BUTTON) { paymentLeftCreatedState(type) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the [listTag] lazy list until the field tagged [fieldTag] is composed and on-screen, then types
|
||||
* [text] into it. The transfer forms lay their fields out in a [androidx.compose.foundation.lazy.LazyColumn],
|
||||
* so a lower field is not composed on a short screen until scrolled to — without this, the input silently
|
||||
* matches nothing on Firebase's smaller devices.
|
||||
* [text] into it, retrying until the field actually holds a value. The transfer forms lay their fields out
|
||||
* in a [androidx.compose.foundation.lazy.LazyColumn], so a lower field is not composed on a short screen
|
||||
* until scrolled to — without this, the input silently matches nothing on Firebase's smaller devices.
|
||||
*
|
||||
* Scroll and type have to be retried as a unit: focusing a field opens the IME, which resizes the list and
|
||||
* can recycle the *next* field back out of the composition between the scroll and the keystrokes, failing
|
||||
* the input with "the node is no longer in the tree". Re-typing is safe because [performTextInput] resolves
|
||||
* and focuses the node before inserting anything, so a throw here leaves the field untouched.
|
||||
*/
|
||||
private fun typeIntoField(listTag: String, fieldTag: String, text: String) {
|
||||
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
|
||||
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
|
||||
scheduler.flushUntil {
|
||||
if (fieldIsFilled(fieldTag)) {
|
||||
true
|
||||
} else {
|
||||
runCatching {
|
||||
composeRule.onNodeWithTag(listTag).performScrollToNode(hasTestTag(fieldTag))
|
||||
composeRule.onNodeWithTag(fieldTag).performTextInput(text)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Compose node tagged [tag] once per flush until [settled] holds, which defaults to the node
|
||||
* no longer being present (the transfer forms navigate away on submit).
|
||||
* Whether the field tagged [fieldTag] holds any text. Deliberately not an equality check against what was
|
||||
* typed: the IBAN field renders through a [androidx.compose.ui.text.input.VisualTransformation] that groups
|
||||
* the digits, so what is on screen never matches what was sent.
|
||||
*/
|
||||
private fun fieldIsFilled(fieldTag: String): Boolean {
|
||||
val node = composeRule.onAllNodesWithTag(fieldTag).fetchSemanticsNodes().singleOrNull() ?: return false
|
||||
return node.config.getOrNull(SemanticsProperties.EditableText)?.text?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Compose node tagged [tag] once per flush until [settled] reports the click was acted on.
|
||||
*
|
||||
* A dispatched click is not proof the click landed, so "did it throw" is not a usable stop condition:
|
||||
* [performClick] succeeds on a disabled button, and these forms pin their submit button above an animating
|
||||
* IME, so a click issued while the insets are still settling is delivered to the strip the button has
|
||||
* already vacated and silently does nothing. Retrying until the flow has actually advanced makes the step
|
||||
* independent of where the button was a frame ago.
|
||||
* [performClick] succeeds on a disabled button, and the transfer forms pin their submit button above an
|
||||
* animating IME, so a click issued while the insets are still settling is delivered to the strip the button
|
||||
* has already vacated and silently does nothing. Retrying until [settled] holds makes the step independent
|
||||
* of where the button was a frame ago.
|
||||
*
|
||||
* [settled] has to observe the payment rather than the screen, which is why there is no UI-shaped default:
|
||||
* submit opens a view-based dialog *over* the form rather than replacing it, so the button stays composed
|
||||
* and whether Compose is even queryable depends on which window happens to be on top — it throws while the
|
||||
* dialog shows and reports matches once it goes away.
|
||||
*/
|
||||
private fun clickWhenReady(
|
||||
tag: String,
|
||||
settled: () -> Boolean = { composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty() }
|
||||
) {
|
||||
private fun clickWhenReady(tag: String, settled: () -> Boolean) {
|
||||
scheduler.flushUntil {
|
||||
if (settled()) {
|
||||
true
|
||||
@@ -235,6 +264,17 @@ class CheckoutFlowDriver(
|
||||
scheduler.triggerActions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether checkout has taken the submission for [type]: its payment row exists and has moved off the
|
||||
* CREATED state it is written in. Monotonic, unlike anything on screen — a payment never returns to
|
||||
* CREATED. Errors count as taken so a rejected submission stops the click loop and surfaces itself in the
|
||||
* spec's own assertion instead of being retried until the timeout hides it.
|
||||
*/
|
||||
private fun paymentLeftCreatedState(type: InAppPaymentType): Boolean {
|
||||
val payment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
||||
return payment != null && payment.state != InAppPaymentTable.State.CREATED
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a Compose gateway-selector button and waits for the bottom sheet to dismiss. For methods
|
||||
* other than Google Pay the checkout then navigates to the method's fragment; Google Pay instead
|
||||
|
||||
Reference in New Issue
Block a user