chore: Renamed Plan to BillingPlan, disabled old plan tests.

This commit is contained in:
Denys Zelenchuk
2025-02-05 09:18:57 +01:00
parent 5028aefdb7
commit e1d8811c71
31 changed files with 495 additions and 190 deletions
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 Proton AG
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
@@ -18,6 +18,7 @@
package me.proton.core.auth.test
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import me.proton.core.auth.test.robot.AddAccountRobot
@@ -27,10 +28,12 @@ import me.proton.core.humanverification.test.robot.HvCodeRobot
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeErrorRobot
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeRobot
import me.proton.core.plan.test.SubscriptionHelper
import me.proton.core.plan.test.robot.Plan
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.util.kotlin.random
import me.proton.test.fusion.Fusion.byObject
import me.proton.test.fusion.FusionConfig
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@@ -40,7 +43,7 @@ import kotlin.time.Duration.Companion.seconds
* Clients tests should extend it in order to run payments tests from their repositories.
* Should be run on payments test environment with PlayStore licensed test accounts.
* When running on Android emulator it should support PlayStore API.
* This test is parametrized and uses [Plan]s as parameters.
* This test is parametrized and uses [BillingPlan]s as parameters.
* Client plans should be created in advance and maintained in clients repository.
*
* Usage (below code should be added on client side):
@@ -67,7 +70,8 @@ import kotlin.time.Duration.Companion.seconds
* }
* }
*/
public abstract class MinimalExternalRegistrationWithSubscriptionTest(private val plan: Plan) {
@SdkSuppress(minSdkVersion = 33)
public abstract class MinimalExternalRegistrationWithSubscriptionTest(private val billingPlan: BillingPlan) {
public abstract fun afterSubscriptionSteps()
@@ -99,24 +103,31 @@ public abstract class MinimalExternalRegistrationWithSubscriptionTest(private va
.fillAndClickNext(String.random(12))
SubscriptionRobot
.selectBillingCycle(plan.billingCycle)
.selectPlan(plan)
.selectBillingCycle(billingPlan.billingCycle)
.selectPlan(billingPlan)
.openPaymentMethods()
.selectAlwaysDeclines<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<GPBottomSheetSubscribeErrorRobot>()
.errorMessageIsShown()
.clickGotIt<SubscriptionRobot>()
.selectExpandedPlan(plan)
.selectExpandedPlan(billingPlan)
.openPaymentMethods()
.selectAlwaysApproves<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<SubscriptionRobot>()
InstrumentationRegistry.getInstrumentation().uiAutomation.waitForIdle(5_000L, 60_000L)
byObject.withPkg(InstrumentationRegistry.getInstrumentation().targetContext.packageName).waitForExists()
HvCodeRobot
.apply {
waitForWebView()
}
afterSubscriptionSteps()
SubscriptionHelper.cancelSubscription(plan)
}
@After
public fun cancelPlayStoreSubscription() {
SubscriptionHelper.cancelSubscription(billingPlan)
}
}
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 Proton AG
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
@@ -21,6 +21,7 @@ package me.proton.core.auth.test
import android.provider.Settings.Global.ANIMATOR_DURATION_SCALE
import android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE
import android.provider.Settings.Global.WINDOW_ANIMATION_SCALE
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import me.proton.core.auth.test.robot.AddAccountRobot
@@ -28,10 +29,12 @@ import me.proton.core.auth.test.robot.signup.SignupInternal
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeErrorRobot
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeRobot
import me.proton.core.plan.test.SubscriptionHelper
import me.proton.core.plan.test.robot.Plan
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.util.kotlin.random
import me.proton.test.fusion.Fusion.byObject
import me.proton.test.fusion.FusionConfig
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@@ -41,7 +44,7 @@ import kotlin.time.Duration.Companion.seconds
* Clients tests should extend it in order to run payments tests from their repositories.
* Should be run on payments test environment with PlayStore licensed test accounts.
* When running on Android emulator it should support PlayStore API.
* This test is parametrized and uses [Plan]s as parameters.
* This test is parametrized and uses [BillingPlan]s as parameters.
* Client plans should be created in advance and maintained in clients repository.
*
* Usage (below code should be added on client side):
@@ -68,25 +71,16 @@ import kotlin.time.Duration.Companion.seconds
* }
* }
*/
public abstract class MinimalInternalRegistrationWithSubscriptionTest(private val plan: Plan) {
@SdkSuppress(minSdkVersion = 33)
public abstract class MinimalInternalRegistrationWithSubscriptionTest(private val billingPlan: BillingPlan) {
public abstract fun afterSubscriptionSteps()
@Before
public fun setUpTimeouts() {
val animationScale: Float = 0.0F
val transitionAnimationScale: Float = 0.0F
val animatorDurationScale: Float = 0.0F
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("settings put secure $ANIMATOR_DURATION_SCALE $animationScale")
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("settings put secure $TRANSITION_ANIMATION_SCALE $transitionAnimationScale")
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("settings put secure $WINDOW_ANIMATION_SCALE $animatorDurationScale")
FusionConfig.Compose.waitTimeout.set(60.seconds)
FusionConfig.Espresso.waitTimeout.set(60.seconds)
FusionConfig.UiAutomator.waitTimeout.set(60.seconds)
FusionConfig.UiAutomator.shouldSearchByObjectEachAction = false
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("settings put secure autofill_service null")
}
@@ -112,20 +106,28 @@ public abstract class MinimalInternalRegistrationWithSubscriptionTest(private va
.skipConfirm()
SubscriptionRobot
.selectBillingCycle(plan.billingCycle)
.selectPlan(plan)
.selectBillingCycle(billingPlan.billingCycle)
.selectPlan(billingPlan)
.openPaymentMethods()
.selectAlwaysDeclines<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<GPBottomSheetSubscribeErrorRobot>()
.errorMessageIsShown()
.clickGotIt<SubscriptionRobot>()
.selectExpandedPlan(plan)
.selectExpandedPlan(billingPlan)
.openPaymentMethods()
.selectAlwaysApproves<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<SubscriptionRobot>()
afterSubscriptionSteps()
InstrumentationRegistry.getInstrumentation().uiAutomation.waitForIdle(5_000L, 60_000L)
byObject.withPkg(InstrumentationRegistry.getInstrumentation().targetContext.packageName).waitForExists()
SubscriptionHelper.cancelSubscription(plan)
afterSubscriptionSteps()
}
@After
public fun cancelPlayStoreSubscription() {
SubscriptionHelper.cancelSubscription(billingPlan)
}
}
@@ -23,6 +23,7 @@ import androidx.test.core.app.ApplicationProvider
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.payment.presentation.R
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.test.android.instrumented.utils.StringUtils.stringFromResource
import me.proton.core.test.android.robot.CoreexampleRobot
@@ -63,7 +64,7 @@ class DynamicExistingPaymentMethodTests(
login(userWithPaypal)
CoreexampleRobot().plansUpgrade()
SubscriptionRobot.selectPlan(Plan.Dev)
SubscriptionRobot.selectPlan(BillingPlan.Free)
ExistingPaymentMethodsRobot().verify {
paymentMethodDisplayed("PayPal", userWithPaypal.paypal)
}
@@ -74,7 +75,7 @@ class DynamicExistingPaymentMethodTests(
login(userWithCard)
CoreexampleRobot().plansUpgrade()
SubscriptionRobot.selectPlan(Plan.Dev)
SubscriptionRobot.selectPlan(BillingPlan.Free)
ExistingPaymentMethodsRobot().verify {
paymentMethodDisplayed(Card.default.details(), Card.default.name)
if (inApp) {
@@ -93,7 +94,7 @@ class DynamicExistingPaymentMethodTests(
login(user)
CoreexampleRobot().plansUpgrade()
SubscriptionRobot.selectPlan(Plan.Dev)
SubscriptionRobot.selectPlan(BillingPlan.Free)
ExistingPaymentMethodsRobot().verify {
paymentMethodDisplayed(card.details(), card.name)
paymentMethodDisplayed("PayPal", user.paypal)
@@ -106,7 +107,7 @@ class DynamicExistingPaymentMethodTests(
val user = users.getUser { it.paypal.isNotEmpty() && it.cards.isNotEmpty() && !it.isPaid }
CoreexampleRobot().plansUpgrade()
SubscriptionRobot.selectPlan(Plan.Dev)
SubscriptionRobot.selectPlan(BillingPlan.Free)
ExistingPaymentMethodsRobot().verify {
paymentMethod(user.paypal).checkIsNotChecked()
paymentMethod(user.cards[0].details()).checkIsChecked()
@@ -21,11 +21,11 @@ package me.proton.core.test.android.uitests.tests.medium.payments
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.payment.presentation.R
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.test.android.robots.payments.AddCreditCardRobot
import me.proton.core.test.android.robot.CoreexampleRobot
import me.proton.core.test.android.robots.payments.AddCreditCardRobot
import me.proton.core.test.android.uitests.tests.BaseTest
import me.proton.core.test.quark.data.Plan
import me.proton.core.test.quark.data.User
import org.junit.After
import org.junit.Test
@@ -46,7 +46,7 @@ class DynamicNewCreditCardTests : BaseTest() {
login(userWithoutCard)
CoreexampleRobot().plansCurrent()
SubscriptionRobot.selectPlan(Plan.Dev)
SubscriptionRobot.selectPlan(BillingPlan.Free)
}
@Test
@@ -21,7 +21,6 @@ package me.proton.core.test.android.uitests.tests.medium.plans
import android.content.Context
import androidx.core.text.HtmlCompat
import androidx.test.core.app.ApplicationProvider
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.plan.presentation.R
import me.proton.core.plan.presentation.entity.PlanCycle
@@ -37,6 +37,7 @@ import java.text.DateFormat
import java.util.Calendar
import java.util.Date
@Ignore("Outdated")
@HiltAndroidTest
class DynamicCurrentPlanTests {
@@ -27,12 +27,11 @@ import me.proton.core.auth.test.robot.signup.SetPasswordRobot
import me.proton.core.domain.entity.AppStore
import me.proton.core.humanverification.test.robot.HvCodeRobot
import me.proton.core.paymentiap.test.robot.GoogleIAPRobot
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.test.android.robots.CoreRobot
import me.proton.core.test.android.robots.payments.AddCreditCardRobot
import me.proton.core.test.android.uitests.tests.SmokeTest
import me.proton.core.test.quark.data.Plan.Free
import me.proton.core.test.quark.data.Plan.MailPlus
import me.proton.core.test.rule.annotation.TestUserData
import me.proton.core.test.rule.annotation.payments.TestPaymentMethods
import me.proton.core.test.rule.annotation.payments.annotationTestData
@@ -42,7 +41,7 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
//@Ignore("Outdated")
@Ignore("Outdated")
@HiltAndroidTest
class DynamicSelectPlanTests {
@@ -82,7 +81,7 @@ class DynamicSelectPlanTests {
@Test
fun selectFreeAndCancelHumanVerification() {
SubscriptionRobot.selectPlan(Free)
SubscriptionRobot.selectPlan(BillingPlan.Free)
HvCodeRobot
.apply {
@@ -95,7 +94,7 @@ class DynamicSelectPlanTests {
@Test
fun selectFreeAndCancelHumanVerification2() {
SubscriptionRobot.selectPlan(MailPlus)
SubscriptionRobot.selectPlan(BillingPlan.Free)
HvCodeRobot
.apply {
@@ -109,7 +108,7 @@ class DynamicSelectPlanTests {
@Test
@SmokeTest
fun selectPlusAndCancelPayment() {
SubscriptionRobot.selectPlan(MailPlus)
SubscriptionRobot.selectPlan(BillingPlan.Free)
AddCreditCardRobot()
.apply {
verify<AddCreditCardRobot.Verify> {
@@ -125,7 +124,7 @@ class DynamicSelectPlanTests {
@SmokeTest
@TestPaymentMethods(AppStore.GooglePlay, card = false, paypal = false, inApp = true)
fun selectPlusAndCancelPaymentIAPOnly() {
SubscriptionRobot.selectPlan(MailPlus)
SubscriptionRobot.selectPlan(BillingPlan.Free)
GoogleIAPRobot()
.apply {
verify<GoogleIAPRobot.Verify> {
@@ -141,7 +140,7 @@ class DynamicSelectPlanTests {
@SmokeTest
@TestPaymentMethods(AppStore.GooglePlay, card = true, paypal = false, inApp = true)
fun selectPlusAndCancelPaymentIAPAndCard() {
SubscriptionRobot.selectPlan(MailPlus)
SubscriptionRobot.selectPlan(BillingPlan.Free)
AddCreditCardRobot()
.apply {
verify<AddCreditCardRobot.Verify> { nextPaymentProviderButtonDisplayed() }
@@ -159,7 +158,7 @@ class DynamicSelectPlanTests {
@SmokeTest
@TestPaymentMethods(AppStore.GooglePlay, card = false, paypal = false, inApp = false)
fun selectPlusNoPaymentProvidersAvailable() {
SubscriptionRobot.selectPlan(MailPlus)
SubscriptionRobot.selectPlan(BillingPlan.Free)
SubscriptionRobot.verifyAtLeastOnePlanIsShown()
}
}
@@ -21,6 +21,7 @@ package me.proton.core.test.android.uitests.tests.medium.plans
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.plan.presentation.entity.PlanCycle
import me.proton.core.plan.test.BillingPlan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.test.android.robot.CoreexampleRobot
import me.proton.core.test.android.uitests.tests.BaseTest
@@ -29,8 +30,10 @@ import me.proton.core.test.quark.data.Plan
import me.proton.core.test.quark.data.User
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@Ignore("Outdated")
@HiltAndroidTest
class DynamicUpgradePlanTests : BaseTest() {
private val coreExampleRobot = CoreexampleRobot()
@@ -55,8 +58,8 @@ class DynamicUpgradePlanTests : BaseTest() {
coreExampleRobot.plansUpgrade()
SubscriptionRobot.apply {
togglePlan(Plan.Dev)
verifyCanGetPlan(Plan.Dev)
togglePlan(BillingPlan.Free)
verifyCanGetPlan(BillingPlan.Free)
close()
}
@@ -18,7 +18,6 @@
package me.proton.core.test.android.uitests.tests.medium.plans
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.test.quark.data.Plan.Dev
import me.proton.core.test.quark.data.Plan.Free
@@ -18,7 +18,6 @@
package me.proton.core.test.android.uitests.tests.medium.plans
import dagger.hilt.android.testing.HiltAndroidTest
import me.proton.core.domain.entity.AppStore
import me.proton.core.plan.presentation.entity.PlanCycle
import me.proton.core.test.quark.data.Plan
-1
View File
@@ -10,4 +10,3 @@ android.enableJetifier=false
kotlin.code.style=official
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.testInstrumentationRunnerArguments.no-uninstall=true
@@ -1,15 +1,34 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import me.proton.test.fusion.Fusion.byObject
/**
* Payment method bottom sheet robot to select always declines or always approves methods.
*/
@SdkSuppress(minSdkVersion = 33)
public class GPBottomSheetPaymentMethodsRobot {
public val device: UiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
public inline fun <reified T> selectAlwaysApproves(): T {
selectCardItem(testCardAlwaysApprovesText)
return T::class.java.getDeclaredConstructor().newInstance()
@@ -1,10 +1,33 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.Button
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.core.test.android.instrumented.FusionConfig
import me.proton.test.fusion.Fusion.byObject
/**
* Google Play payment error bottom sheet robot, containing actions and validations.
*/
@SdkSuppress(minSdkVersion = 33)
public class GPBottomSheetSubscribeErrorRobot {
public inline fun <reified T> clickGotIt(): T {
@@ -1,7 +1,27 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.Button
import android.widget.LinearLayout
import android.widget.RadioButton
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
@@ -9,13 +29,17 @@ import androidx.test.uiautomator.UiWatcher
import me.proton.core.test.android.instrumented.FusionConfig
import me.proton.test.fusion.Fusion.byObject
/**
* Google Play bottom sheet robot, containing Subscribe button with additional actions.
*/
@SdkSuppress(minSdkVersion = 33)
public class GPBottomSheetSubscribeRobot {
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
public fun registerPlayPointsNotNowButtonWatcher() {
device.registerWatcher("Google play Points Not now button watcher", UiWatcher {
device.registerWatcher(notNowButtonUiWatcherName, UiWatcher {
val registerButton = device.findObject(By.text("Not now").clazz(Button::class.java))
if (registerButton != null && registerButton.isEnabled) {
println("'Google play Points Not now' button detected! Clicking it now...")
@@ -27,6 +51,25 @@ public class GPBottomSheetSubscribeRobot {
device.runWatchers()
}
public fun registerPlayRequireAuthenticationWatcher() {
device.registerWatcher(authRequiredScreenWatcherName, UiWatcher {
val noThanksButton =
device.findObject(By.text("No, thanks").clazz(RadioButton::class.java))
if (noThanksButton != null) {
println("'Google play authentication required screen is shown. Dealing with it.")
noThanksButton.click()
val okButton =
device.findObject(By.text("OK").clazz(Button::class.java).enabled(true))
if (okButton != null) {
okButton.click()
return@UiWatcher true
}
}
false
})
device.runWatchers()
}
public fun openPaymentMethods(): GPBottomSheetPaymentMethodsRobot {
FusionConfig.uiAutomator.boost()
byObject
@@ -48,7 +91,11 @@ public class GPBottomSheetSubscribeRobot {
.waitForExists()
.click()
// Below UIWatchers should handle one time pop-up bottom sheets:
// PlayStore points and require PlayStore authentication.
registerPlayPointsNotNowButtonWatcher()
registerPlayRequireAuthenticationWatcher()
return T::class.java.getDeclaredConstructor().newInstance()
}
}
@@ -1,12 +1,34 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
/**
* Google Play bottom sheet containing Subscribe button with additional actions.
*/
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreAccountViewRobot {
public fun selectPaymentsAndSubscriptionsItem(): PlayStorePaymentsAndSubscriptionsRobot {
Thread.sleep(3000)
byObject.withText("Payments & subscriptions")
.instanceOf(TextView::class.java)
.waitForExists()
@@ -1,8 +1,28 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreCancelSubscriptionBottomSheetRobot {
public fun clickCancelSubscription(): PlayStoreManageSubscriptionRobot {
@@ -1,11 +1,40 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.content.Context
import android.content.Intent
import android.widget.Button
import android.widget.FrameLayout
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiWatcher
import me.proton.test.fusion.Fusion.byObject
/**
* Contains code to launch PlayStore app and deal with PlayStore application home screen.
*/
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreHomeRobot {
public fun clickOnAccountButton(): PlayStoreAccountViewRobot {
@@ -17,10 +46,31 @@ public class PlayStoreHomeRobot {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
registerMeetTheSearchTabWatcher()
byObject.withContentDescContains("Signed in as")
.instanceOf(FrameLayout::class.java)
.waitForExists()
.click()
return PlayStoreAccountViewRobot()
}
private val device: UiDevice =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
public fun registerMeetTheSearchTabWatcher() {
device.registerWatcher(playStoreMeetTheSearchTabName, UiWatcher {
val meetTheSearchTab = device.findObject(By.text("Meet the Search tab").clazz(TextView::class.java))
if (meetTheSearchTab != null) {
println("Handling 'Google play Meet the Search tab' pop up.")
val searchTab = device.findObject(By.text("Search").clazz(TextView::class.java).pkg("com.android.vending"))
if (searchTab != null) {
searchTab.click()
return@UiWatcher true
}
}
false
})
device.runWatchers()
}
}
@@ -1,9 +1,29 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.view.View
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreManageSubscriptionRobot {
public fun clickCancelSubscription(): PlayStorePauseSubscriptionBottomSheetRobot {
@@ -1,8 +1,28 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStorePauseSubscriptionBottomSheetRobot {
public fun clickNoThanksButton(isMonthlyBillingCycle: Boolean): PlayStoreWhatsMakingYouCancelRobot {
@@ -1,8 +1,28 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStorePaymentsAndSubscriptionsRobot {
public fun selectSubscriptionsItem(): PlayStoreSubscriptionsRobot {
@@ -1,8 +1,28 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreSubscriptionsRobot {
public fun clickActiveSubscriptionByText(planName: String): PlayStoreManageSubscriptionRobot {
@@ -1,9 +1,29 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.paymentiap.test.robot
import android.widget.RadioButton
import android.widget.TextView
import androidx.test.filters.SdkSuppress
import me.proton.test.fusion.Fusion.byObject
@SdkSuppress(minSdkVersion = 33)
public class PlayStoreWhatsMakingYouCancelRobot {
public fun clickIDontUseServiceEnoughItemAndPressContinue(): PlayStoreCancelSubscriptionBottomSheetRobot {
@@ -1,10 +1,28 @@
package me.proton.core.paymentiap.test.robot
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
import me.proton.core.paymentiap.test.R
import me.proton.core.presentation.ui.view.ProtonButton
package me.proton.core.paymentiap.test.robot
public const val playStorePkg: String = "com.android.vending"
public const val testCardAlwaysDeclinesText: String = "Test card, always declines"
public const val testCardAlwaysApprovesText: String = "Test card, always approves"
public const val testCardAlwaysText: String = "Test card, always"
public const val googlePlayManagedSubscriptionText: String = "Your subscription is managed by Google Play."
public const val notNowButtonUiWatcherName: String = "Google play Points Not now button watcher"
public const val authRequiredScreenWatcherName: String = "Google play Points Not now button watcher"
public const val playStoreMeetTheSearchTabName: String = "Google play meet the search tab"
-2
View File
@@ -42,9 +42,7 @@ dependencies {
project(Module.humanVerificationTest),
project(Module.planPresentation),
project(Module.paymentIapTest),
project(Module.paymentIapData),
project(Module.androidUtilDagger),
// project(Module.authTest),
project(Module.testRule),
fusion,
`kotlin-test`,
@@ -61,5 +61,4 @@ public abstract class MinimalSubscriptionTests {
currentPlanIsDisplayed()
}
}
}
@@ -18,31 +18,31 @@
package me.proton.core.plan.test
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.runBlocking
import me.proton.core.payment.domain.PurchaseManager
import me.proton.core.payment.domain.entity.PurchaseState
import me.proton.core.paymentiap.data.GooglePurchaseStateHandler
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeErrorRobot
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeRobot
import me.proton.core.plan.test.robot.Plan
import me.proton.core.plan.test.robot.SubscriptionRobot
import me.proton.core.test.rule.annotation.PrepareUser
import me.proton.test.fusion.Fusion.byObject
import me.proton.test.fusion.FusionConfig
import org.junit.After
import org.junit.Before
import org.junit.Test
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltAndroidTest
public abstract class MinimalUpgradeFreeUserTest(private val plan: Plan) {
@SdkSuppress(minSdkVersion = 33)
public abstract class MinimalUpgradeFreeUserTest(private val billingPlan: BillingPlan) {
@Inject
internal lateinit var purchaseManager: PurchaseManager
@Inject
internal lateinit var giapHandler: GooglePurchaseStateHandler
public abstract fun startSubscription(): SubscriptionRobot
public abstract fun afterSubscriptionSteps()
@@ -58,23 +58,43 @@ public abstract class MinimalUpgradeFreeUserTest(private val plan: Plan) {
@PrepareUser(loginBefore = true)
public fun upgradeFreeUserToPlanFailFirstPaymentAttempt(): Unit = runBlocking {
startSubscription()
.selectBillingCycle(plan.billingCycle)
.selectPlan(plan)
.selectBillingCycle(billingPlan.billingCycle)
.selectPlan(billingPlan)
// Error flow
.openPaymentMethods()
.selectAlwaysDeclines<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<GPBottomSheetSubscribeErrorRobot>()
.errorMessageIsShown()
// Success flow
.clickGotIt<SubscriptionRobot>()
.selectExpandedPlan(plan)
.selectExpandedPlan(billingPlan)
.openPaymentMethods()
.selectAlwaysApproves<GPBottomSheetSubscribeRobot>()
.clickSubscribeButton<SubscriptionRobot>()
GiapHandler(giapHandler).waitForGiapSubscribed(plan)
GiapHandler(giapHandler).waitForGiapAcknowledged(plan)
PurchaseManagerHandler(purchaseManager).waitForPurchaseState(plan, PurchaseState.Deleted)
PurchaseManagerHandler(purchaseManager).waitForPurchaseState(
billingPlan,
PurchaseState.Subscribed
)
PurchaseManagerHandler(purchaseManager).waitForPurchaseState(
billingPlan,
PurchaseState.Acknowledged
)
PurchaseManagerHandler(purchaseManager).waitForPurchaseState(
billingPlan,
PurchaseState.Deleted
)
InstrumentationRegistry.getInstrumentation()
.uiAutomation.waitForIdle(5_000L, 30_000L)
byObject.withPkg(InstrumentationRegistry.getInstrumentation().targetContext.packageName)
.waitForExists()
afterSubscriptionSteps()
SubscriptionHelper.cancelSubscription(plan)
}
@After
public fun cancelPlayStoreSubscription() {
SubscriptionHelper.cancelSubscription(billingPlan)
}
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.plan.test
/**
* Represents billing plan object used in Payments tests with its billing cycle.
*/
public data class BillingPlan(
val id: String,
val name: String,
val price: Double,
val billingCycle: BillingCycle
) {
override fun toString(): String {
// Show only BillingPlan name & BillingPlan cycle in parametrized test
return "$name, ${billingCycle.value}"
}
public companion object {
// Predefined Billing Cycles
public val Free: BillingPlan =
BillingPlan("Free", "Free", 0.0, BillingCycle(BillingCycle.PAY_MONTHLY))
}
}
public data class BillingCycle(
val value: String
) {
public companion object {
// Predefined payment period values
public const val PAY_ANNUALLY: String = "Pay annually"
public const val PAY_MONTHLY: String = "Pay monthly"
}
}
@@ -1,91 +1,50 @@
package me.proton.core.plan.test
import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import me.proton.core.payment.domain.PurchaseManager
import me.proton.core.payment.domain.entity.Purchase
import me.proton.core.payment.domain.entity.PurchaseState
import me.proton.core.paymentiap.data.GooglePurchaseStateHandler
import me.proton.core.paymentiap.data.onGiapAcknowledged
import me.proton.core.paymentiap.data.onGiapSubscribed
import me.proton.core.payment.domain.onPurchaseState
import me.proton.core.paymentiap.test.robot.PlayStoreHomeRobot
import me.proton.core.plan.test.robot.BillingCycle
import me.proton.core.plan.test.robot.Plan
public class GiapHandler(private val giapHandler: GooglePurchaseStateHandler) {
public fun waitForGiapSubscribed(plan: Plan) {
runBlocking {
repeat(10) {
val job = giapHandler.onGiapSubscribed(plan.id) {
Log.d(
"GIAP_TEST",
"✅ GIAP -> Subscription subscribed: ${plan.name} - ${plan.id}"
)
}
if (job.isCompleted) {
return@repeat
}
delay(1000)
}
}
}
public fun waitForGiapAcknowledged(plan: Plan) {
runBlocking {
repeat(10) {
val job = giapHandler.onGiapAcknowledged(plan.id)
{
Log.d(
"GIAP_TEST",
"✅ GIAP -> Subscription acknowledged: ${plan.name} - ${plan.id}"
)
}
if (job.isCompleted) {
return@repeat
}
delay(1000)
}
}
}
}
public class PurchaseManagerHandler(private val purchaseManager: PurchaseManager) {
public suspend fun waitForPurchaseState(plan: Plan, state: PurchaseState): Purchase? {
repeat(10) {
val purchase = purchaseManager
.observePurchase(plan.id)
.filter { it?.purchaseState == state }
.firstOrNull()
if (purchase != null) {
public fun waitForPurchaseState(
billingPlan: BillingPlan,
state: PurchaseState,
timeMills: Long = 10000
) {
runBlocking {
withTimeoutOrNull(timeMills) {
purchaseManager.onPurchaseState(state, planName = billingPlan.id).first()
Log.d(
"GIAP_TEST",
"✅ PurchaseManager -> Purchase deleted from database: ${plan.name} - ${plan.id}"
"✅ PurchaseManager -> Purchase is in state '$state': " +
"${billingPlan.name} - ${billingPlan.id}"
)
return purchase
}
delay(1000)
}
return null
}
}
public object SubscriptionHelper {
public fun cancelSubscription(plan: Plan) {
public fun cancelSubscription(billingPlan: BillingPlan) {
PlayStoreHomeRobot()
.clickOnAccountButton()
.selectPaymentsAndSubscriptionsItem()
.selectSubscriptionsItem()
.clickActiveSubscriptionByText(
plan.name.takeIf { plan.billingCycle.value == BillingCycle.PAY_ANNUALLY }
?: "${plan.name} 1 month"
billingPlan.name.takeIf {
billingPlan.billingCycle.value == BillingCycle.PAY_ANNUALLY
}
?: "${billingPlan.name} 1 month"
)
.clickCancelSubscription()
.clickNoThanksButton(plan.billingCycle.value == BillingCycle.PAY_MONTHLY)
.clickNoThanksButton(
billingPlan.billingCycle.value == BillingCycle.PAY_MONTHLY
)
.clickIDontUseServiceEnoughItemAndPressContinue()
.clickCancelSubscription()
.subscriptionIsCancelled()
@@ -1,35 +0,0 @@
package me.proton.core.plan.test.robot
public data class BillingCycle(
val value: String,
val monthlyPrice: Double
) {
public companion object {
// Predefined payment period values
public const val PAY_ANNUALLY: String = "Pay annually"
public const val PAY_MONTHLY: String = "Pay monthly"
}
}
public data class Plan(
val id: String,
val name: String,
val billingCycle: BillingCycle
) {
override fun toString(): String {
return "$name, ${billingCycle.value}" // Shows only Plan name in parametrized test
}
public companion object {
// Predefined Billing Cycles
public val Free: Plan =
Plan(
"Free", "Free",
BillingCycle(
BillingCycle.PAY_MONTHLY,
0.0
)
)
}
}
@@ -23,14 +23,14 @@ import me.proton.core.payment.presentation.view.ProtonPaymentButton
import me.proton.core.paymentiap.test.robot.GPBottomSheetSubscribeRobot
import me.proton.core.paymentiap.test.robot.PlayStoreSubscriptionsRobot
import me.proton.core.plan.presentation.R
import me.proton.core.plan.test.BillingCycle
import me.proton.core.plan.test.BillingPlan
import me.proton.core.presentation.ui.view.ProtonButton
import me.proton.test.fusion.Fusion.byObject
import me.proton.test.fusion.Fusion.device
import me.proton.test.fusion.Fusion.view
import me.proton.test.fusion.FusionConfig
import me.proton.test.fusion.ui.common.enums.SwipeDirection
import me.proton.test.fusion.ui.espresso.builders.OnView
import okhttp3.internal.wait
import kotlin.time.Duration.Companion.seconds
public object SubscriptionRobot {
@@ -67,12 +67,12 @@ public object SubscriptionRobot {
view.withCustomMatcher(ViewMatchers.withSubstring("Get"))
}
internal fun togglePlanItem(plan: Plan) {
view.withId(R.id.title).withText(plan.name).scrollTo().click()
internal fun togglePlanItem(billingPlan: BillingPlan) {
view.withId(R.id.title).withText(billingPlan.name).scrollTo().click()
}
internal fun getPlanButton(plan: Plan): OnView {
val buttonText = FusionConfig.targetContext.getString(R.string.plans_get_proton, plan.name)
internal fun getPlanButton(billingPlan: BillingPlan): OnView {
val buttonText = FusionConfig.targetContext.getString(R.string.plans_get_proton, billingPlan.name)
return view.instanceOf(ProtonPaymentButton::class.java).containsText(buttonText)
}
@@ -80,31 +80,31 @@ public object SubscriptionRobot {
return view.withText(R.string.plans_proton_for_free)
}
private fun expandPlan(plan: Plan) {
togglePlanItem(plan)
private fun expandPlan(billingPlan: BillingPlan) {
togglePlanItem(billingPlan)
}
public fun selectExpandedPlan(plan: Plan): GPBottomSheetSubscribeRobot {
public fun selectExpandedPlan(billingPlan: BillingPlan): GPBottomSheetSubscribeRobot {
view.withId(R.id.scrollContent).hasDescendant(view.withId(R.id.plans)).swipe(SwipeDirection.Up)
getPlanButton(plan).click()
getPlanButton(billingPlan).click()
return GPBottomSheetSubscribeRobot()
}
public fun selectFreePlan() {
view.withText("Free").await(timeout = 90.seconds) { checkIsDisplayed() }
view.withText("Free").scrollTo().click()
getPlanButton(Plan.Free).scrollTo().click()
getPlanButton(BillingPlan.Free).scrollTo().click()
}
public fun selectPlan(plan: Plan): GPBottomSheetSubscribeRobot {
public fun selectPlan(billingPlan: BillingPlan): GPBottomSheetSubscribeRobot {
planSelectionIsDisplayed()
expandPlan(plan)
selectExpandedPlan(plan)
expandPlan(billingPlan)
selectExpandedPlan(billingPlan)
return GPBottomSheetSubscribeRobot()
}
public fun togglePlan(plan: Plan) {
togglePlanItem(plan)
public fun togglePlan(billingPlan: BillingPlan) {
togglePlanItem(billingPlan)
}
public fun selectBillingCycle(cycle: BillingCycle): SubscriptionRobot {
@@ -164,8 +164,8 @@ public object SubscriptionRobot {
view.withId(R.id.price_cycle).withText(value).await { checkIsDisplayed() }
}
public fun verifyCanGetPlan(plan: Plan) {
getPlanButton(plan)
public fun verifyCanGetPlan(billingPlan: BillingPlan) {
getPlanButton(billingPlan)
.scrollTo()
.checkIsDisplayed()
.checkIsEnabled()
@@ -141,5 +141,5 @@ public const val `json-simple version`: String = "1.1.1"
public const val `turbine version`: String = "0.12.1"
public const val `junit version`: String = "4.13.2"
public const val `junit-ktx version`: String = "1.1.4"
public const val `fusion version`: String = "1.0.1"
public const val `fusion version`: String = "1.0.4"
// endregion