diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt index 5db26cee91..1755b3e37f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowDriver.kt @@ -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