mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-04 20:34:14 +01:00
Harden donation checkout instrumentation tests.
This commit is contained in:
@@ -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)
|
||||
|
||||
+9
-3
@@ -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<CheckoutFlowActivity>(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()))
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -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<CheckoutFlowActivity>(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<CheckoutFlowActivity>(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<CheckoutFlowActivity>(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<CheckoutFlowActivity>(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()))
|
||||
}
|
||||
|
||||
|
||||
+34
-84
@@ -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<PaymentData> {
|
||||
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
|
||||
}
|
||||
|
||||
mockkConstructor(GooglePayApi::class)
|
||||
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
|
||||
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
|
||||
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
|
||||
arg<GooglePayApi.PaymentRequestCallback>(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<CheckoutFlowActivity>.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<CheckoutFlowActivity>.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PaymentData> {
|
||||
every { toJson() } returns GOOGLE_PAY_PAYMENT_DATA_JSON
|
||||
}
|
||||
|
||||
mockkConstructor(GooglePayApi::class)
|
||||
every { anyConstructed<GooglePayApi>().queryIsReadyToPay() } returns Completable.complete()
|
||||
every { anyConstructed<GooglePayApi>().requestPayment(any(), any(), any()) } just Runs
|
||||
every { anyConstructed<GooglePayApi>().onActivityResult(any(), any(), any(), any(), any()) } answers {
|
||||
arg<GooglePayApi.PaymentRequestCallback>(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"}"""
|
||||
}
|
||||
}
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+66
@@ -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<View>,
|
||||
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<View>): Boolean {
|
||||
return try {
|
||||
onView(withId(recyclerViewId)).perform(RecyclerViewActions.scrollTo<RecyclerView.ViewHolder>(holderMatcher))
|
||||
true
|
||||
} catch (e: PerformException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
-34
@@ -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<View> = 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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -226,6 +226,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="03fb659177c8618e47425925c11bc91f384703ea26c265be9b25cd79292b511c" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.appcompat" name="appcompat" version="1.0.0">
|
||||
<artifact name="appcompat-1.0.0.aar">
|
||||
<sha256 value="994254e12036cbd3326b2819a4d8bc3bef139f45022cfb0b1086dc58d36d56ce" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.appcompat" name="appcompat" version="1.6.1">
|
||||
<artifact name="appcompat-1.6.1.aar">
|
||||
<sha256 value="7ea5573b93ababd3bd32312451c6ea48a662b03a140dda81aebe75776a20a422" origin="Generated by Gradle"/>
|
||||
@@ -289,6 +294,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="a8b17513949e5db6c9601c30be19df953762dd877512f1e2cfcfae81d2440944" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.asynclayoutinflater" name="asynclayoutinflater" version="1.0.0">
|
||||
<artifact name="asynclayoutinflater-1.0.0.aar">
|
||||
<sha256 value="f7eab60c57addd94bb06275832fe7600611beaaae1a1ec597c231956faf96c8b" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.asynclayoutinflater" name="asynclayoutinflater" version="1.1.0">
|
||||
<artifact name="asynclayoutinflater-1.1.0.aar">
|
||||
<sha256 value="b301fa6c663b6b66cbdf4e82f88f15f7704a218e945d882761c3902d378f599d" origin="Generated by Gradle"/>
|
||||
@@ -1425,6 +1435,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="b23732edbb3511d937fea1ffef047b0e6c001b50c1921f0d959fc384d706ec6a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.coordinatorlayout" name="coordinatorlayout" version="1.0.0">
|
||||
<artifact name="coordinatorlayout-1.0.0.aar">
|
||||
<sha256 value="e508c695489493374d942bf7b4ee02abf7571d25aac4c622e57d6cd5cd29eb73" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.coordinatorlayout" name="coordinatorlayout" version="1.1.0">
|
||||
<artifact name="coordinatorlayout-1.1.0.aar">
|
||||
<sha256 value="44a9e30abf56af1025c52a0af506fee9c4131aa55efda52f9fd9451211c5e8cb" origin="Generated by Gradle"/>
|
||||
@@ -2957,6 +2972,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="6f11cf65e4bf8444995cc73e5aa3f81b082d66e720c0592bc5fc80d3f73850f7" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.recyclerview" name="recyclerview" version="1.0.0">
|
||||
<artifact name="recyclerview-1.0.0.aar">
|
||||
<sha256 value="06956fb1ac014027ca9d2b40469a4b42aa61b4957bb11848e1ff352701ab4548" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.recyclerview" name="recyclerview" version="1.1.0">
|
||||
<artifact name="recyclerview-1.1.0.aar">
|
||||
<sha256 value="f0d2b5a67d0a91ee1b1c73ef2b636a81f3563925ddd15a1d4e1c41ec28de7a4f" origin="Generated by Gradle"/>
|
||||
@@ -3167,6 +3187,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="5a35f1e23888d0eee77bfe481b000710aaac14edf123a8485edbd8fbcd18e059" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.slidingpanelayout" name="slidingpanelayout" version="1.0.0">
|
||||
<artifact name="slidingpanelayout-1.0.0.aar">
|
||||
<sha256 value="76bffb7cefbf780794d8817002dad1562f3e27c0a9f746d62401c8edb30aeede" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.slidingpanelayout" name="slidingpanelayout" version="1.2.0">
|
||||
<artifact name="slidingpanelayout-1.2.0.aar">
|
||||
<sha256 value="5f53339be2a4f90a9abea3571dd59e70a8a49e7f15dd82974a3898b4652e8714" origin="Generated by Gradle"/>
|
||||
@@ -3306,6 +3331,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="970311c47119928a2e406a88892a3d270387cc5a49a181a1c44511105b41b818" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.test.espresso" name="espresso-contrib" version="3.4.0">
|
||||
<artifact name="espresso-contrib-3.4.0.aar">
|
||||
<sha256 value="71c0250a83982ec0bf6269016f754d6c67c3923806852b1a25fe056480d8766a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.test.espresso" name="espresso-core" version="3.4.0">
|
||||
<artifact name="espresso-core-3.4.0.aar">
|
||||
<sha256 value="fa2f4d80afb8c17828d25be75a99f241c28ec9fe971e2b86e09e62a839113282" origin="Generated by Gradle"/>
|
||||
@@ -3469,6 +3499,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="baba97bfc2834fbd29275ce3e250ac814305bd0d4beb8b3594248424cc6ff385" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.transition" name="transition" version="1.0.0">
|
||||
<artifact name="transition-1.0.0.aar">
|
||||
<sha256 value="a00a0f763f401abcecda9b0eafcb738929c5801b111a9a414b81a193d0f4008d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.transition" name="transition" version="1.5.0">
|
||||
<artifact name="transition-1.5.0.aar">
|
||||
<sha256 value="0aa66a0ea406d25a1091f96a3b753b4b12e44fdc43b91ec52c17831e9c31f54b" origin="Generated by Gradle"/>
|
||||
@@ -3485,11 +3520,21 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="c763c376fd6d39e51477c902b315e58ca2a1f47d4b099cb99f49a148bd61c560" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.vectordrawable" name="vectordrawable" version="1.0.0">
|
||||
<artifact name="vectordrawable-1.0.0.aar">
|
||||
<sha256 value="507591926583875e3a0e6c46272235f323b4574759b5c02f0ab7a51d2ae7320d" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.vectordrawable" name="vectordrawable" version="1.1.0">
|
||||
<artifact name="vectordrawable-1.1.0.aar">
|
||||
<sha256 value="46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.vectordrawable" name="vectordrawable-animated" version="1.0.0">
|
||||
<artifact name="vectordrawable-animated-1.0.0.aar">
|
||||
<sha256 value="26c3a0cf0a9a9a7d235a0b00f2f37e431d52d9952751e3eb7c90b4b52c236cf1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.vectordrawable" name="vectordrawable-animated" version="1.1.0">
|
||||
<artifact name="vectordrawable-animated-1.1.0.aar">
|
||||
<sha256 value="76da2c502371d9c38054df5e2b248d00da87809ed058f3363eae87ce5e2403f8" origin="Generated by Gradle"/>
|
||||
@@ -4375,6 +4420,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="f49e88107f90ef0d1250d8504e8eba18be8532b1286203c92f28bf6bd15814ff" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.material" name="material" version="1.0.0">
|
||||
<artifact name="material-1.0.0.aar">
|
||||
<sha256 value="7680e381a3c03798d999b2e441caadd8a56a0a808e108024a67af9fe26c11adc" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.material" name="material" version="1.12.0">
|
||||
<artifact name="material-1.12.0.aar">
|
||||
<sha256 value="4a672941b626b9ab91ae893ed22598ea53ad69125c858c0a59fa9b90daa5cb08" origin="Generated by Gradle"/>
|
||||
@@ -6357,6 +6407,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="bc78d32d7ffb141ee27e4fb77df04259d842c899e7e8eaf912f990d7253bd3b4" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.checkerframework" name="checker" version="3.1.1">
|
||||
<artifact name="checker-3.1.1.jar">
|
||||
<sha256 value="a915ce7b4e21ffc3af94ae074254bd395770da75afc31abfb5805fec862f7941" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.checkerframework" name="checker-qual" version="3.33.0">
|
||||
<artifact name="checker-qual-3.33.0.jar">
|
||||
<sha256 value="e316255bbfcd9fe50d165314b85abb2b33cb2a66a93c491db648e498a82c2de1" origin="Generated by Gradle"/>
|
||||
|
||||
Reference in New Issue
Block a user