Fix flaky UI interactions in donation checkout tests.

This commit is contained in:
Alex Hart
2026-09-02 16:11:28 -03:00
parent bf3c7c080c
commit 20cfaa6588
@@ -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