@file:Suppress("TooManyFunctions") package co.electroniccoin.zcash.ui.screenshot import android.content.Context import android.os.Build import android.os.LocaleList import androidx.activity.viewModels import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ApplicationProvider import androidx.test.core.graphics.writeToTestStorage import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.screenshot.captureToBitmap import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.filters.LargeTest import androidx.test.filters.MediumTest import androidx.test.filters.SdkSuppress import cash.z.ecc.android.sdk.fixture.WalletAddressFixture import cash.z.ecc.android.sdk.model.MonetarySeparators import cash.z.ecc.android.sdk.model.SeedPhrase import cash.z.ecc.sdk.fixture.MemoFixture import cash.z.ecc.sdk.fixture.SeedPhraseFixture import co.electriccoin.zcash.spackle.FirebaseTestLabUtil import co.electriccoin.zcash.test.UiTestPrerequisites import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.NavigationTargets import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.common.viewmodel.SecretState import co.electriccoin.zcash.ui.design.component.ConfigurationOverride import co.electriccoin.zcash.ui.design.component.UiMode import co.electriccoin.zcash.ui.screen.account.AccountTag import co.electriccoin.zcash.ui.screen.home.HomeTag import co.electriccoin.zcash.ui.screen.restore.RestoreTag import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.junit.Rule import org.junit.Test import kotlin.time.Duration.Companion.seconds private const val DEFAULT_TIMEOUT_MILLISECONDS = 10_000L /* * This screenshot implementation does not change the system-wide configuration, but rather * injects a Context with a modified Configuration to change the uiMode and Locale. * * This works by: * 1. Main Activity wraps the Composable with an Override * 2. Main Activity exposes a Flow where a ConfigurationOverride can be set * 3. We use an altered resContext in the tests instead of Application Context in order to load * the right resources for comparison. * * Benefits of this implementation are that we do not modify system-wide values and don't require * additional permissions to run these tests. * * Limitations of this implementation are that any views outside of Compose will not be updated, which * can include the on-screen keyboard, system dialogs (like permissions), or other UI elements. * * An alternative implementation would be to use AppCompatActivity as the parent class for MainActivity, * then rely on the AppCompat APIs for changing uiMode and Locale. This doesn't bring much benefit over * our approach (it still has the problem with system dialogs and the keyboard), and it requires that * we pull in the appcompat library. */ class ScreenshotTest : UiTestPrerequisites() { companion object { fun takeScreenshot( tag: String, screenshotName: String ) { onView(isRoot()) .captureToBitmap() .writeToTestStorage("$screenshotName - $tag") } } @get:Rule val composeTestRule = createAndroidComposeRule(MainActivity::class.java) private fun navigateTo(route: String) = runBlocking { withContext(Dispatchers.Main) { composeTestRule.activity.navControllerForTesting.navigate(route) } } private fun ComposeContentTestRule.navigateInHomeTab(destinationTag: String) { onNodeWithTag(destinationTag).also { it.assertExists() it.performClick() } } private fun runWith( uiMode: UiMode, locale: String, action: (Context, String) -> Unit ) { val configurationOverride = ConfigurationOverride(uiMode, LocaleList.forLanguageTags(locale)) composeTestRule.activity.configurationOverrideFlow.value = configurationOverride val applicationContext = ApplicationProvider.getApplicationContext() val configuration = configurationOverride.newConfiguration(applicationContext.resources.configuration) val resContext = applicationContext.createConfigurationContext(configuration) action(resContext, "$uiMode-$locale") } @Test @MediumTest fun takeScreenshotsForRestoreWalletLightEnXA() { runWith(UiMode.Light, "en-XA") { context, tag -> takeScreenshotsForRestoreWallet(context, tag) } } @Test @MediumTest fun takeScreenshotsForRestoreWalletLightArXB() { runWith(UiMode.Light, "ar-XB") { context, tag -> takeScreenshotsForRestoreWallet(context, tag) } } @Test @MediumTest fun takeScreenshotsForRestoreWalletLightEnUS() { runWith(UiMode.Light, "en-US") { context, tag -> takeScreenshotsForRestoreWallet(context, tag) } } // Dark mode was introduced in Android Q @Test @MediumTest @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) fun takeScreenshotsForRestoreWalletDarkEnUS() { runWith(UiMode.Dark, "en-US") { context, tag -> takeScreenshotsForRestoreWallet(context, tag) } } @Suppress("LongMethod", "CyclomaticComplexMethod") private fun takeScreenshotsForRestoreWallet( resContext: Context, tag: String ) { // TODO [#286]: Screenshot tests fail on Firebase Test Lab // TODO [#286]: https://github.com/Electric-Coin-Company/zashi-android/issues/286 if (FirebaseTestLabUtil.isFirebaseTestLab(ApplicationProvider.getApplicationContext())) { return } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } composeTestRule.onNodeWithText( text = resContext.getString( R.string.onboarding_import_existing_wallet ), ignoreCase = true ).also { it.performScrollTo() it.assertExists() it.performClick() } composeTestRule.onNodeWithText(resContext.getString(R.string.restore_title)).also { it.assertExists() } takeScreenshot(tag, "Import 1") val seedPhraseSplitLength = SeedPhraseFixture.new().split.size SeedPhraseFixture.new().split.forEachIndexed { index, string -> composeTestRule.onNodeWithTag(RestoreTag.SEED_WORD_TEXT_FIELD).also { it.performTextInput(string) // Take a screenshot half-way through filling in the seed phrase if (index == seedPhraseSplitLength / 2) { takeScreenshot(tag, "Import 2") } } } composeTestRule.waitUntil { composeTestRule.activity.viewModels().value.userWordList.current.value.size == SeedPhrase.SEED_PHRASE_SIZE } composeTestRule.onNodeWithText( text = resContext.getString(R.string.restore_seed_button_next), ignoreCase = true ).also { // Even with waiting for the word list in the view model, there's some latency before the button is enabled composeTestRule.waitUntil(5.seconds.inWholeMilliseconds) { runCatching { it.assertIsEnabled() }.isSuccess } it.performScrollTo() it.performClick() } composeTestRule.onNodeWithText(resContext.getString(R.string.restore_birthday_header)).also { it.assertExists() } takeScreenshot(tag, "Import 3") composeTestRule.onNodeWithText( text = resContext.getString(R.string.restore_birthday_button_restore), ignoreCase = true ).also { it.performScrollTo() it.performClick() } } @Test @LargeTest fun takeScreenshotsForNewWalletAndRestOfAppLightEnXA() { runWith(UiMode.Light, "en-XA") { context, tag -> takeScreenshotsForNewWalletAndRestOfApp(context, tag) } } @Test @LargeTest fun takeScreenshotsForNewWalletAndRestOfAppLightArXB() { runWith(UiMode.Light, "ar-XB") { context, tag -> takeScreenshotsForNewWalletAndRestOfApp(context, tag) } } @Test @LargeTest fun takeScreenshotsForNewWalletAndRestOfAppLightEnUS() { runWith(UiMode.Light, "en-US") { context, tag -> takeScreenshotsForNewWalletAndRestOfApp(context, tag) } } // Dark mode was introduced in Android Q @Test @LargeTest @SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) fun takeScreenshotsForNewWalletAndRestOfAppDarkEnUS() { runWith(UiMode.Dark, "en-US") { context, tag -> takeScreenshotsForNewWalletAndRestOfApp(context, tag) } } private fun takeScreenshotsForNewWalletAndRestOfApp( resContext: Context, tag: String ) { // TODO [#286]: Screenshot tests fail on Firebase Test Lab // TODO [#286]: https://github.com/Electric-Coin-Company/zashi-android/issues/286 if (FirebaseTestLabUtil.isFirebaseTestLab(ApplicationProvider.getApplicationContext())) { return } // These are the home screen bottom navigation sub-screens onboardingScreenshots(resContext, tag, composeTestRule) recoveryScreenshots(resContext, tag, composeTestRule) composeTestRule.navigateInHomeTab(HomeTag.TAB_ACCOUNT) accountScreenshots(tag, composeTestRule) composeTestRule.navigateInHomeTab(HomeTag.TAB_SEND) sendZecScreenshots(resContext, tag, composeTestRule) composeTestRule.navigateInHomeTab(HomeTag.TAB_RECEIVE) receiveZecScreenshots(resContext, tag, composeTestRule) composeTestRule.navigateInHomeTab(HomeTag.TAB_BALANCES) balancesScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.HISTORY) transactionHistoryScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.SETTINGS) settingsScreenshots(resContext, tag, composeTestRule) // These are the Settings screen items // We could manually click on each one, which is a better integration test but a worse screenshot test navigateTo(NavigationTargets.SEED_RECOVERY) seedScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.SUPPORT) supportScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.EXPORT_PRIVATE_DATA) exportPrivateDataScreenshots(resContext, tag, composeTestRule) navigateTo(NavigationTargets.ABOUT) aboutScreenshots(resContext, tag, composeTestRule) } } private fun onboardingScreenshots( resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.None } // Welcome screen composeTestRule.onNodeWithText(resContext.getString(R.string.onboarding_header)).also { it.assertExists() ScreenshotTest.takeScreenshot(tag, "Onboarding 1") } composeTestRule.onNodeWithText( text = resContext.getString(R.string.onboarding_create_new_wallet), ignoreCase = true ).also { it.performClick() } // Security Warning screen composeTestRule.onNodeWithText(text = resContext.getString(R.string.security_warning_acknowledge)).also { it.assertExists() it.performClick() ScreenshotTest.takeScreenshot(tag, "Security Warning") } composeTestRule.onNodeWithText( text = resContext.getString(R.string.security_warning_confirm), ignoreCase = true ).also { it.performClick() } } @Suppress("LongMethod", "CyclomaticComplexMethod") private fun recoveryScreenshots( resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.NeedsBackup } composeTestRule.onNodeWithText(resContext.getString(R.string.new_wallet_recovery_header)).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Recovery 1") composeTestRule.onNodeWithText( text = resContext.getString(R.string.new_wallet_recovery_button_finished), ignoreCase = true ).also { it.assertExists() it.performScrollTo() it.performClick() } } private fun accountScreenshots( tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.secretState.value is SecretState.Ready } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.walletSnapshot.value != null } composeTestRule.onNodeWithTag(AccountTag.STATUS_VIEWS).also { it.assertExists() ScreenshotTest.takeScreenshot(tag, "Account 1") } } private fun balancesScreenshots( resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { // TODO [#1127]: Implement Balances screen // TODO [#1127]: https://github.com/Electric-Coin-Company/zashi-android/issues/1127 composeTestRule.onNodeWithText(resContext.getString(R.string.not_implemented_yet)).also { it.assertExists() ScreenshotTest.takeScreenshot(tag, "Balances 1") } } private fun settingsScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.settings_backup_wallet), ignoreCase = true)).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Settings 1") } private fun transactionHistoryScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.history_title))).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Transaction History 1") } // This screen is not currently navigable from the app @Suppress("UnusedPrivateMember") private fun requestZecScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.request_title))).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Request 1") } private fun receiveZecScreenshots( resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.addresses.value != null } composeTestRule.onNode( hasContentDescription( value = resContext.getString(R.string.receive_unified_content_description), ignoreCase = true ) ).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Receive 1") } private fun sendZecScreenshots( resContext: Context, tag: String, composeTestRule: AndroidComposeTestRule, MainActivity> ) { composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.synchronizer.value != null } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.spendingKey.value != null } composeTestRule.waitUntil(DEFAULT_TIMEOUT_MILLISECONDS) { composeTestRule.activity.walletViewModel.walletSnapshot.value != null } composeTestRule.onNode(hasText(resContext.getString(R.string.send_title))).also { it.assertExists() } // Screenshot: Empty form ScreenshotTest.takeScreenshot(tag, "Send 1") composeTestRule.onNodeWithText(resContext.getString(R.string.send_amount)).also { val separators = MonetarySeparators.current() it.performTextInput("0${separators.decimal}123") } composeTestRule.onNodeWithText(resContext.getString(R.string.send_to)).also { it.performTextInput(WalletAddressFixture.UNIFIED_ADDRESS_STRING) } composeTestRule.onNodeWithText(resContext.getString(R.string.send_memo)).also { it.performTextInput(MemoFixture.MEMO_STRING) } // To close soft keyboard to reveal the send button Espresso.closeSoftKeyboard() // Screenshot: Fulfilled form ScreenshotTest.takeScreenshot(tag, "Send 2") // The rest of the Send screens (i.e. Send Confirmation) depends on sufficient available balance which can' // achieve in this kind of the test in a reasonable time thanks to block synchronization } private fun supportScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.support_header))).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Support 1") } private fun exportPrivateDataScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.export_data_header))).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Export Private Data 1") } private fun aboutScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode( hasText(resContext.getString(R.string.about_title).uppercase()) ).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "About 1") } private fun seedScreenshots( resContext: Context, tag: String, composeTestRule: ComposeTestRule ) { composeTestRule.onNode(hasText(resContext.getString(R.string.seed_recovery_header))).also { it.assertExists() } ScreenshotTest.takeScreenshot(tag, "Seed 1") }