From 310e90be84bfa35b22d7de004acbaf0e623aff26 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 2 Jul 2026 11:29:58 -0300 Subject: [PATCH] Harden donation checkout instrumentation tests. --- app/build.gradle.kts | 9 ++ ...ckoutFlowActivityTest__OneTimeDonations.kt | 12 +- ...outFlowActivityTest__RecurringDonations.kt | 27 ++-- .../donate/CheckoutFlowPermitTestSupport.kt | 118 +++++------------- .../securesms/testing/GooglePayTestRule.kt | 49 ++++++++ .../testing/InAppPaymentTestWaiters.kt | 57 +++++++++ .../securesms/testing/InAppPaymentsRule.kt | 27 ++++ .../actions/RecyclerViewInteractions.kt | 66 ++++++++++ .../RecyclerViewScrollToBottomAction.kt | 34 ----- gradle/test-libs.versions.toml | 1 + gradle/verification-metadata.xml | 55 ++++++++ 11 files changed, 324 insertions(+), 131 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/GooglePayTestRule.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentTestWaiters.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewInteractions.kt delete mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewScrollToBottomAction.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d76eeb2e6a..5eb3c669e0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -792,6 +792,15 @@ dependencies { androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(testLibs.androidx.test.ext.junit) androidTestImplementation(testLibs.espresso.core) + androidTestImplementation(testLibs.espresso.contrib) { + // espresso-contrib transitively pulls the full checkerframework jar (only its annotations are needed), + // whose MANIFEST.MF collides with other test dependencies during androidTest resource merging. + exclude(group = "org.checkerframework", module = "checker") + // accessibility-test-framework drags in an ancient com.google.protobuf:protobuf-lite:3.0.1 whose + // GeneratedMessageLite wins the merged dex and lacks registerDefaultInstance(Class, GeneratedMessageLite), + // crashing tests at runtime. We only use RecyclerViewActions from contrib, not the accessibility checks. + exclude(group = "com.google.android.apps.common.testing.accessibility.framework") + } androidTestImplementation(testLibs.androidx.test.core) androidTestImplementation(testLibs.androidx.test.core.ktx) androidTestImplementation(testLibs.androidx.test.ext.junit.ktx) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt index 92ed103c73..42515be967 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__OneTimeDonations.kt @@ -22,12 +22,16 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.signal.core.util.deleteAll import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.testing.GooglePayTestRule import org.thoughtcrime.securesms.testing.InAppPaymentsRule import org.thoughtcrime.securesms.testing.RxTestSchedulerRule import org.thoughtcrime.securesms.testing.SignalActivityRule -import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction +import org.thoughtcrime.securesms.testing.actions.scrollToDescendant @Suppress("ClassName") @RunWith(AndroidJUnit4::class) @@ -51,6 +55,7 @@ class CheckoutFlowActivityTest__OneTimeDonations { @Before fun setUp() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) startJobLoopForTests() } @@ -61,16 +66,17 @@ class CheckoutFlowActivityTest__OneTimeDonations { val scenario = ActivityScenario.launch(intent) rxRule.defaultTestScheduler.triggerActions() + scrollToDescendant(R.id.recycler, withId(R.id.boost_1), rxRule.defaultTestScheduler) onView(allOf(withId(R.id.boost_1), isClickable())).perform(ViewActions.click()) rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler) onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) rxRule.defaultTestScheduler.triggerActions() scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.ONE_TIME_DONATION) - awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt index b16e2dee98..b7816acab2 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivityTest__RecurringDonations.kt @@ -21,6 +21,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.signal.core.util.deleteAll import org.signal.donations.InAppPaymentType import org.signal.libsignal.net.RequestResult import org.signal.network.NetworkResult @@ -28,14 +29,17 @@ import org.signal.network.exceptions.NonSuccessfulResponseCodeException import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.permits.DonationPermits +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.testing.GooglePayTestRule import org.thoughtcrime.securesms.testing.InAppPaymentsRule import org.thoughtcrime.securesms.testing.RxTestSchedulerRule import org.thoughtcrime.securesms.testing.SignalActivityRule -import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction +import org.thoughtcrime.securesms.testing.actions.scrollToDescendant import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.SubscriberId import java.math.BigDecimal @@ -65,6 +69,7 @@ class CheckoutFlowActivityTest__RecurringDonations { @Before fun setUp() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) startJobLoopForTests() } @@ -76,14 +81,15 @@ class CheckoutFlowActivityTest__RecurringDonations { @Test fun givenRecurringDonations_whenILoadScreen_thenIExpectMonthlySelected() { ActivityScenario.launch(intent) + scrollToDescendant(R.id.recycler, withId(R.id.monthly), rxRule.defaultTestScheduler) onView(withId(R.id.monthly)).check(matches(isSelected())) } @Test fun givenNoCurrentDonation_whenILoadScreen_thenIExpectContinueButton() { ActivityScenario.launch(intent) - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) - onView(withText("Continue")).check(matches(isDisplayed())) + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler) + onView(withText(R.string.DonateToSignalFragment__continue)).check(matches(isDisplayed())) } @Test @@ -94,8 +100,9 @@ class CheckoutFlowActivityTest__RecurringDonations { rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler) onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed())) + scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler) onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed())) } @@ -107,7 +114,7 @@ class CheckoutFlowActivityTest__RecurringDonations { rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__cancel_subscription), rxRule.defaultTestScheduler) onView(withText(R.string.SubscribeFragment__cancel_subscription)).check(matches(isDisplayed())) onView(withText(R.string.SubscribeFragment__cancel_subscription)).perform(ViewActions.click()) onView(withText(R.string.SubscribeFragment__confirm_cancellation)).check(matches(isDisplayed())) @@ -122,7 +129,7 @@ class CheckoutFlowActivityTest__RecurringDonations { rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.SubscribeFragment__update_subscription), rxRule.defaultTestScheduler) onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isDisplayed())) onView(withText(R.string.SubscribeFragment__update_subscription)).check(matches(isNotEnabled())) } @@ -134,13 +141,13 @@ class CheckoutFlowActivityTest__RecurringDonations { val scenario = ActivityScenario.launch(intent) rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler) onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) rxRule.defaultTestScheduler.triggerActions() scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION) - awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) } @@ -154,13 +161,13 @@ class CheckoutFlowActivityTest__RecurringDonations { val scenario = ActivityScenario.launch(intent) rxRule.defaultTestScheduler.triggerActions() - onView(withId(R.id.recycler)).perform(RecyclerViewScrollToBottomAction) + scrollToDescendant(R.id.recycler, withText(R.string.DonateToSignalFragment__continue), rxRule.defaultTestScheduler) onView(withText(R.string.DonateToSignalFragment__continue)).perform(ViewActions.click()) rxRule.defaultTestScheduler.triggerActions() scenario.selectGooglePay(composeRule, rxRule.defaultTestScheduler, InAppPaymentType.RECURRING_DONATION) - awaitDonationErrorDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) + awaitDialog(rxRule.defaultTestScheduler, R.string.DonationsErrors__error_processing_payment) onView(withText(R.string.DonationsErrors__your_payment)).inRoot(isDialog()).check(matches(isDisplayed())) } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt index 2e4056f489..5370a6f7cc 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowPermitTestSupport.kt @@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate import android.app.Activity import android.content.Intent -import android.os.SystemClock import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag @@ -18,18 +17,8 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.RootMatchers.isDialog import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.gms.wallet.PaymentData -import io.mockk.Runs import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.mockkConstructor -import io.mockk.unmockkConstructor -import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.TestScheduler -import org.junit.rules.ExternalResource -import org.signal.donations.GooglePayApi import org.signal.donations.InAppPaymentType import org.signal.libsignal.net.RequestResult import org.signal.network.rest.RestStatusCodeError @@ -38,36 +27,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorTestTags import org.thoughtcrime.securesms.dependencies.AppDependencies - -/** - * Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without - * launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation. - */ -class GooglePayTestRule : ExternalResource() { - override fun before() { - val paymentData = mockk { - every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON - } - - mockkConstructor(GooglePayApi::class) - every { anyConstructed().queryIsReadyToPay() } returns Completable.complete() - every { anyConstructed().requestPayment(any(), any(), any()) } just Runs - every { anyConstructed().onActivityResult(any(), any(), any(), any(), any()) } answers { - arg(4).onSuccess(paymentData) - } - } - - override fun after() { - unmockkConstructor(GooglePayApi::class) - } -} - -/** - * Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses - * `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is - * serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs. - */ -private const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}""" +import org.thoughtcrime.securesms.testing.flushUntil /** * Forces real donation-permit acquisition to fail at the issuer, exercising the permit code path through @@ -89,30 +49,33 @@ fun startJobLoopForTests() { /** * Selects Google Pay in the Compose gateway selector and feeds the stubbed Google Pay result back into the * checkout, navigating to the payment-in-progress screen where the pipeline runs. + * + * The gateway selector is populated by Rx work on [scheduler], so we [flushUntil] the button is present + * rather than sleeping. Selecting Google Pay dismisses the gateway sheet and hands a fragment result back to + * the checkout, which runs `launchGooglePay` -> `provideGatewayRequestForGooglePay` on [scheduler]; only then + * does the checkout's subscriber consume a [GooglePayComponent.googlePayResultPublisher] emission + * (`consumeGatewayRequestForGooglePay` returns null until then, and the publisher is a hot PublishSubject that + * drops earlier emissions). So we [flushUntil] the sheet has dismissed — a real signal that the click was + * fully processed — before dispatching the result, rather than pumping a fixed number of times and racing. */ fun ActivityScenario.selectGooglePay( composeRule: ComposeTestRule, scheduler: TestScheduler, inAppPaymentType: InAppPaymentType ) { - val deadline = SystemClock.uptimeMillis() + 10_000 - var present = false - while (SystemClock.uptimeMillis() < deadline && !present) { - scheduler.triggerActions() - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - present = try { - composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty() - } catch (e: IllegalStateException) { - false - } - if (!present) { - Thread.sleep(100) - } + scheduler.flushUntil { + composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isNotEmpty() } - check(present) { "Google Pay button never appeared in the gateway selector." } composeRule.onNodeWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).performClick() - pump(scheduler, iterations = 20) + + scheduler.flushUntil { + // Once the gateway sheet dismisses there is no Compose hierarchy left, so fetchSemanticsNodes throws + // rather than returning empty; treat both the empty list and the missing hierarchy as "sheet gone". + runCatching { + composeRule.onAllNodesWithTag(GatewaySelectorTestTags.GOOGLE_PAY_BUTTON).fetchSemanticsNodes().isEmpty() + }.getOrDefault(true) + } onActivity { activity -> (activity as GooglePayComponent).googlePayResultPublisher.onNext( @@ -126,34 +89,21 @@ fun ActivityScenario.selectGooglePay( } /** - * Advances the Rx [scheduler] and pumps the main looper until the checkout error dialog with [titleResId] is - * displayed, bridging the real JobManager-backed setup job, the Rx observers, and dialog rendering. + * Waits for a dialog whose title is [titleResId] to be displayed. Matches any dialog by title (error, + * confirmation, thanks, etc.) — it is not specific to error dialogs. + * + * [flushUntil] advances the Rx pipeline (which may create the payment, enqueue the real setup job, and react + * to its committed state) while yielding real time for any job to run, until the dialog renders. A single + * condition-driven pump rather than a fixed-duration wall-clock poll. Letting the Espresso check throw (rather + * than collapsing it to a boolean) lets [flushUntil] chain the last matcher failure as the timeout cause. */ -fun awaitDonationErrorDialog(scheduler: TestScheduler, titleResId: Int, timeoutMillis: Long = 15_000) { - val deadline = SystemClock.uptimeMillis() + timeoutMillis - var lastFailure: Throwable? = null - - while (SystemClock.uptimeMillis() < deadline) { - scheduler.triggerActions() - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - - try { - onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed())) - return - } catch (t: Throwable) { - lastFailure = t - } - - Thread.sleep(100) - } - - throw AssertionError("Donation error dialog ($titleResId) was not displayed within ${timeoutMillis}ms.", lastFailure) -} - -private fun pump(scheduler: TestScheduler, iterations: Int, intervalMs: Long = 100) { - repeat(iterations) { - scheduler.triggerActions() - InstrumentationRegistry.getInstrumentation().waitForIdleSync() - Thread.sleep(intervalMs) +fun awaitDialog( + scheduler: TestScheduler, + titleResId: Int, + timeoutMillis: Long = 15_000 +) { + scheduler.flushUntil(timeoutMillis) { + onView(withText(titleResId)).inRoot(isDialog()).check(matches(isDisplayed())) + true } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GooglePayTestRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GooglePayTestRule.kt new file mode 100644 index 0000000000..7002f7aea8 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/GooglePayTestRule.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing + +import com.google.android.gms.wallet.PaymentData +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import io.reactivex.rxjava3.core.Completable +import org.junit.rules.ExternalResource +import org.signal.donations.GooglePayApi + +/** + * Makes Google Pay appear available and return a fake [com.google.android.gms.wallet.PaymentData] without + * launching the real Google Pay sheet, allowing checkout to be driven to the payment pipeline in instrumentation. + */ +class GooglePayTestRule : ExternalResource() { + override fun before() { + val paymentData = mockk { + every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON + } + + mockkConstructor(GooglePayApi::class) + every { anyConstructed().queryIsReadyToPay() } returns Completable.complete() + every { anyConstructed().requestPayment(any(), any(), any()) } just Runs + every { anyConstructed().onActivityResult(any(), any(), any(), any(), any()) } answers { + arg(4).onSuccess(paymentData) + } + } + + override fun after() { + unmockkConstructor(GooglePayApi::class) + } + + companion object { + /** + * Minimal but well-formed Google Pay payload. [org.signal.donations.GooglePayPaymentSource] parses + * `paymentMethodData.tokenizationData.token` (itself a JSON object with an `id`) when the source is + * serialized into the setup job, so a relaxed mock that returns an empty body fails before the job runs. + */ + const val GOOGLE_PAY_PAYMENT_DATA_JSON = """{"paymentMethodData":{"tokenizationData":{"token":"{\"id\":\"tok_test\"}"}},"email":"test@signal.org"}""" + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentTestWaiters.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentTestWaiters.kt new file mode 100644 index 0000000000..9c6831b2cb --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentTestWaiters.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing + +import android.os.SystemClock +import androidx.test.platform.app.InstrumentationRegistry +import io.reactivex.rxjava3.schedulers.TestScheduler + +/** + * Timing primitives for in-app-payment instrumentation tests. + * + * The checkout flow runs against two clocks: the Rx pipeline that drives the UI (creating the payment, + * enqueuing the setup job, reacting to state changes, rendering dialogs) runs on the test's [TestScheduler] + * (virtual time), while the setup job itself executes on a live JobManager worker thread (real time). Neither + * clock can be waited on alone — the scheduler must be advanced for the pipeline to make progress, and the + * test must yield real time for the job to run and commit its result. [flushUntil] does both. + */ + +/** + * Advances virtual Rx time on this [TestScheduler], drains the main looper, and yields briefly for real + * JobManager work, repeating until [condition] holds or [timeoutMs] elapses. + * + * [TestScheduler.triggerActions] runs the pipeline's scheduled work (which may enqueue a job or process a + * database update), `waitForIdleSync` renders whatever that produced, and the short sleep lets the real setup + * job run on its worker thread and commit the next state before the loop advances the scheduler again to pick + * it up. Throws once [timeoutMs] is exceeded, which means the expected UI state never materialised. + * + * [condition] may either return `false` or throw (e.g. an Espresso `check` that has not yet matched) to signal + * "not satisfied"; a thrown failure is retained and chained as the cause of the timeout [AssertionError] so a + * flake surfaces the underlying Espresso error rather than an opaque "condition not satisfied". + */ +fun TestScheduler.flushUntil(timeoutMs: Long = 15_000, condition: () -> Boolean) { + val deadline = SystemClock.uptimeMillis() + timeoutMs + var lastFailure: Throwable? = null + + while (true) { + triggerActions() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + + try { + if (condition()) { + return + } + } catch (t: Throwable) { + lastFailure = t + } + + if (SystemClock.uptimeMillis() >= deadline) { + throw AssertionError("Condition was not satisfied within ${timeoutMs}ms of flushing the checkout pipeline.", lastFailure) + } + + Thread.sleep(50) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt index 45d0edcfd1..6a108bf1b5 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/InAppPaymentsRule.kt @@ -11,7 +11,9 @@ import org.junit.rules.ExternalResource import org.signal.core.util.JsonUtils import org.signal.network.NetworkResult import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration +import org.whispersystems.signalservice.internal.push.WhoAmIResponse /** * Sets up some common infrastructure for on-device InAppPayment testing @@ -21,6 +23,8 @@ class InAppPaymentsRule : ExternalResource() { initialiseConfigurationResponse() initialisePutSubscription() initialiseSetArchiveBackupId() + initialiseSetAccountAttributes() + initialiseAccountAndSubscriptionLookups() } private fun initialiseConfigurationResponse() { @@ -46,4 +50,27 @@ class InAppPaymentsRule : ExternalResource() { every { triggerBackupIdReservation(any(), any(), any()) } returns NetworkResult.Success(Unit) } } + + private fun initialiseSetAccountAttributes() { + AppDependencies.accountApi.apply { + every { setAccountAttributes(any()) } returns NetworkResult.Success(Unit) + } + } + + /** + * Starting the real job loop lets background jobs unrelated to the assertion under test run against the strict + * API mocks (e.g. [org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob] querying whoAmI and the + * active subscription). Stub these lookups so those jobs hit a handled path and terminate quietly instead of + * throwing [io.mockk.MockKException] on a job thread and polluting the logs. End-to-end coverage of that + * pipeline is tracked separately; here we only keep the logs clean. + */ + private fun initialiseAccountAndSubscriptionLookups() { + AppDependencies.accountApi.apply { + every { whoAmI() } returns NetworkResult.Success(WhoAmIResponse(number = "+15555550123")) + } + + AppDependencies.donationsApi.apply { + every { getSubscription(any()) } returns NetworkResult.Success(ActiveSubscription.EMPTY) + } + } } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewInteractions.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewInteractions.kt new file mode 100644 index 0000000000..a114ae33d0 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewInteractions.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing.actions + +import android.os.SystemClock +import android.view.View +import androidx.annotation.IdRes +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.PerformException +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.hamcrest.Matcher + +/** + * Scrolls the [RecyclerView] with id [recyclerViewId] to the view holder whose item view matches [target], or + * whose item view contains a descendant matching [target] (e.g. a preset button inside a container row), + * binding it if necessary, then returns. Off-screen presets/buttons are brought on-screen before a click or + * assertion regardless of device size — important for Firebase Test Lab's varied screens. + * + * The DSL screens use [androidx.recyclerview.widget.ListAdapter], which diffs `submitList` on a background + * thread and posts the result to the main thread. espresso-contrib's [RecyclerViewActions.scrollTo] scans the + * adapter once and fails fast if that diff has not yet committed, so we retry within [timeoutMs], pumping the + * main looper (and, if supplied, advancing [scheduler] to run the Rx work that produces the list) between + * attempts. This is the async-diff analogue of the codebase's existing poll-until-ready test idiom; Android + * exposes no deterministic completion hook for the differ. + */ +fun scrollToDescendant( + @IdRes recyclerViewId: Int, + target: Matcher, + scheduler: TestScheduler? = null, + timeoutMs: Long = 5_000 +) { + val deadline = SystemClock.uptimeMillis() + timeoutMs + + while (true) { + // A holder's item view may itself be the target (a bare button row) or contain it as a descendant + // (a preset within a container), so try both rather than assume one shape. + if (tryScrollTo(recyclerViewId, target) || tryScrollTo(recyclerViewId, hasDescendant(target))) { + return + } + + if (SystemClock.uptimeMillis() >= deadline) { + throw AssertionError("RecyclerView (id=$recyclerViewId) never bound a holder matching $target within ${timeoutMs}ms.") + } + + scheduler?.triggerActions() + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + Thread.sleep(50) + } +} + +private fun tryScrollTo(@IdRes recyclerViewId: Int, holderMatcher: Matcher): Boolean { + return try { + onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo(holderMatcher)) + true + } catch (e: PerformException) { + false + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewScrollToBottomAction.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewScrollToBottomAction.kt deleted file mode 100644 index af04708038..0000000000 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/actions/RecyclerViewScrollToBottomAction.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.testing.actions - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.UiController -import androidx.test.espresso.ViewAction -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.Matcher - -/** - * Scrolls the RecyclerView to the bottom position. - * - * Borrowed from [https://stackoverflow.com/a/55990445](https://stackoverflow.com/a/55990445) - */ -object RecyclerViewScrollToBottomAction : ViewAction { - override fun getDescription(): String = "scroll RecyclerView to bottom" - - override fun getConstraints(): Matcher = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed()) - - override fun perform(uiController: UiController?, view: View?) { - val recyclerView = view as RecyclerView - val itemCount = recyclerView.adapter?.itemCount - val position = itemCount?.minus(1) ?: 0 - recyclerView.scrollToPosition(position) - uiController?.loopMainThreadUntilIdle() - } -} diff --git a/gradle/test-libs.versions.toml b/gradle/test-libs.versions.toml index 85072f3697..3b4da6ce07 100644 --- a/gradle/test-libs.versions.toml +++ b/gradle/test-libs.versions.toml @@ -21,6 +21,7 @@ androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version. androidx-test-orchestrator = "androidx.test:orchestrator:1.4.1" androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } +espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } kotlinx-coroutines-test = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0" robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } bouncycastle-bcprov-jdk15on = "org.bouncycastle:bcprov-jdk15on:1.70" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3cee6b3664..f3e0fcf440 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -226,6 +226,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -289,6 +294,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -1425,6 +1435,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -2957,6 +2972,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3167,6 +3187,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3306,6 +3331,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3469,6 +3499,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -3485,11 +3520,21 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -4375,6 +4420,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -6357,6 +6407,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + +