├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── colors.xml │ │ │ │ └── themes.xml │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ └── AndroidManifest.xml │ ├── androidTest │ │ └── java │ │ │ └── sergio │ │ │ └── sastre │ │ │ └── uitesting │ │ │ └── myapplication │ │ │ └── ExampleInstrumentedTest.kt │ └── test │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── myapplication │ │ └── LocaleUtilTests.kt ├── proguard-rules.pro └── build.gradle ├── utils ├── consumer-rules.pro ├── .gitignore ├── src │ └── main │ │ ├── java │ │ └── sergio │ │ │ └── sastre │ │ │ └── uitesting │ │ │ └── utils │ │ │ ├── crosslibrary │ │ │ ├── annotations │ │ │ │ └── CrossLibraryScreenshot.kt │ │ │ ├── config │ │ │ │ ├── LibraryConfig.kt │ │ │ │ ├── BitmapCaptureMethod.kt │ │ │ │ ├── ScreenshotConfigForComposable.kt │ │ │ │ └── ScreenshotConfigForView.kt │ │ │ └── testrules │ │ │ │ ├── ScreenshotTestRuleForComposable.kt │ │ │ │ ├── ScreenshotTestRuleForView.kt │ │ │ │ ├── implementations │ │ │ │ ├── ScreenshotLibraryTestRuleForComposable.kt │ │ │ │ ├── shared │ │ │ │ │ ├── SharedScreenshotLibraryTestRuleForComposable.kt │ │ │ │ │ └── SharedScreenshotLibraryTestRuleForView.kt │ │ │ │ └── ScreenshotLibraryTestRuleForView.kt │ │ │ │ └── providers │ │ │ │ ├── ScreenshotLibraryTestRuleForViewProvider.kt │ │ │ │ └── ScreenshotLibraryTestRuleForComposableProvider.kt │ │ │ ├── testrules │ │ │ ├── Condition.kt │ │ │ ├── uiMode │ │ │ │ ├── UiModeSetter.kt │ │ │ │ ├── UiModeTestRule.kt │ │ │ │ ├── AppCompatDelegateUiModeSetter.kt │ │ │ │ └── AdbUiModeSetter.kt │ │ │ ├── locale │ │ │ │ ├── OnActivityCreatedCallback.kt │ │ │ │ ├── SystemLocaleTestRule.kt │ │ │ │ └── ApiDependentInAppLocaleTestRule.kt │ │ │ ├── displaysize │ │ │ │ └── DisplayScaleSetting.kt │ │ │ ├── accessibility │ │ │ │ └── HighTextContrastTestRule.kt │ │ │ ├── animations │ │ │ │ └── DisableAnimationsTestRule.kt │ │ │ └── fontsize │ │ │ │ └── FontScaleSetting.kt │ │ │ ├── common │ │ │ ├── Orientation.kt │ │ │ ├── FontWeight.kt │ │ │ ├── UiMode.kt │ │ │ ├── DisplaySize.kt │ │ │ ├── LocaleListCompat.kt │ │ │ └── FontSize.kt │ │ │ ├── activityscenario │ │ │ ├── ActivityScenarioConfiguratorExt.kt │ │ │ ├── orientation │ │ │ │ ├── OrientationTestWatcher.kt │ │ │ │ └── OrientationHelper.kt │ │ │ ├── ComposableConfigItem.kt │ │ │ ├── ActivityConfigItem.kt │ │ │ ├── ActivityScenarioForViewRule.kt │ │ │ ├── ViewConfigItem.kt │ │ │ └── ActivityScenarioForComposableRule.kt │ │ │ └── fragmentscenario │ │ │ ├── FragmentScenarioConfiguratorExt.kt │ │ │ ├── FragmentScenarioConfiguratorRule.kt │ │ │ └── FragmentConfigItem.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle ├── android-testify ├── .gitignore ├── consumer-rules.pro ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── android_testify │ │ ├── screenshotscenario │ │ ├── ActivityScenarioForComposableRuleExt.kt │ │ ├── ScreenshotScenarioRuleExt.kt │ │ └── ScreenshotScenarioRuleForFragment.kt │ │ ├── AndroidTestifyConfig.kt │ │ └── ScreenshotRuleWithConfigurationForView.kt ├── proguard-rules.pro └── build.gradle ├── robolectric ├── .gitignore ├── src │ └── main │ │ ├── java │ │ └── sergio │ │ │ └── sastre │ │ │ └── uitesting │ │ │ └── robolectric │ │ │ ├── config │ │ │ ├── screen │ │ │ │ ├── ScreenAspect.kt │ │ │ │ ├── RoundScreen.kt │ │ │ │ ├── ScreenOrientation.kt │ │ │ │ ├── ScreenType.kt │ │ │ │ ├── ScreenSize.kt │ │ │ │ └── ScreenDensity.kt │ │ │ └── RobolectricQualifiersBuilder.kt │ │ │ ├── activityscenario │ │ │ ├── RobolectricActivityScenarioConfiguratorExt.kt │ │ │ ├── OnActivityCreatedCallback.kt │ │ │ ├── RobolectricActivityScenarioForViewRule.kt │ │ │ ├── RobolectricActivityScenarioForComposableRule.kt │ │ │ └── RobolectricActivityScenarioForActivityRule.kt │ │ │ ├── utils │ │ │ ├── view │ │ │ │ ├── TestDataForView.kt │ │ │ │ └── TestDataForViewCombinator.kt │ │ │ ├── activity │ │ │ │ ├── TestDataForActivity.kt │ │ │ │ └── TestDataForActivityCombinator.kt │ │ │ ├── fragment │ │ │ │ ├── TestDataForFragment.kt │ │ │ │ └── TestDataForFragmentCombinator.kt │ │ │ └── composable │ │ │ │ ├── TestDataForComposable.kt │ │ │ │ └── TestDataForComposableCombinator.kt │ │ │ └── fragmentscenario │ │ │ ├── RobolectricFragmentScenarioConfiguratorRule.kt │ │ │ └── RobolectricFragmentScenarioConfiguratorExt.kt │ │ └── AndroidManifest.xml ├── proguard-rules.pro └── build.gradle ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── jitpack.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .gitignore ├── shot ├── .gitignore ├── src │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── shot │ │ ├── ActivityScenarioForComposableRuleExt.kt │ │ ├── ShotConfig.kt │ │ └── ShotScreenshotTestRuleForView.kt ├── proguard-rules.pro └── build.gradle ├── dropshots ├── .gitignore ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── dropshots │ │ ├── ActivityScenarioForComposableRuleExt.kt │ │ ├── DropshotsConfig.kt │ │ ├── DropshotsScreenshotTestRuleForComposable.kt │ │ └── ScreenshotTaker.kt ├── proguard-rules.pro └── build.gradle ├── paparazzi ├── .gitignore ├── proguard-rules.pro ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── paparazzi │ │ ├── ContextExt.kt │ │ ├── config │ │ ├── PaparazziForViewTestRuleBuilder.kt │ │ ├── PaparazziForComposableTestRuleBuilder.kt │ │ └── PaparazziScreenshotConfigAdapter.kt │ │ ├── PaparazziScreenshotTestRuleForComposable.kt │ │ └── PaparazziScreenshotTestRuleForView.kt └── build.gradle ├── roborazzi ├── .gitignore ├── proguard-rules.pro ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── roborazzi │ │ ├── FilePathGenerator.kt │ │ ├── DrawToBitmapExt.kt │ │ ├── RobolectricActivityScenarioForComposableTestRuleExt.kt │ │ └── RoborazziScreenshotTestRuleForComposable.kt └── build.gradle ├── mapper-paparazzi ├── .gitignore ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── mapper │ │ └── paparazzi │ │ ├── wrapper │ │ ├── ScreenRatio.kt │ │ ├── Navigation.kt │ │ ├── ScreenSize.kt │ │ ├── RenderingMode.kt │ │ ├── Density.kt │ │ ├── RenderExtension.kt │ │ └── Environment.kt │ │ └── PaparazziConfig.kt ├── proguard-rules.pro └── build.gradle ├── mapper-roborazzi ├── .gitignore ├── src │ └── main │ │ └── java │ │ └── sergio │ │ └── sastre │ │ └── uitesting │ │ └── mapper │ │ └── roborazzi │ │ ├── wrapper │ │ ├── ImageIoFormat.kt │ │ ├── screen │ │ │ ├── RoundScreen.kt │ │ │ ├── ScreenAspect.kt │ │ │ ├── ScreenOrientation.kt │ │ │ ├── ScreenType.kt │ │ │ ├── ScreenSize.kt │ │ │ └── ScreenDensity.kt │ │ ├── AiAssertion.kt │ │ ├── RecordOptions.kt │ │ ├── CaptureType.kt │ │ ├── RoborazziOptions.kt │ │ └── CompareOptions.kt │ │ └── RoborazziConfig.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── LICENSE ├── gradle.properties ├── README.md └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /utils/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /android-testify/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /robolectric/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android-testify/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sergio-sastre] 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | My Application 3 | -------------------------------------------------------------------------------- /jitpack.yml: -------------------------------------------------------------------------------- 1 | jdk: 2 | - openjdk17 3 | before_install: 4 | - sdk install java 17.0.1-open 5 | - sdk use java 17.0.1-open 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergio-sastre/AndroidUiTestingUtils/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /shot/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /dropshots/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /paparazzi/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /roborazzi/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /utils/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | 14 | -------------------------------------------------------------------------------- /mapper-paparazzi/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /mapper-roborazzi/.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /local.properties 3 | .DS_Store 4 | /build 5 | /captures 6 | .externalNativeBuild 7 | .cxx 8 | local.properties 9 | 10 | # idea 11 | *.iml 12 | .idea/ 13 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/annotations/CrossLibraryScreenshot.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.annotations 2 | 3 | annotation class CrossLibraryScreenshot 4 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/ScreenRatio.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | enum class ScreenRatio { 4 | NOTLONG, 5 | LONG, 6 | } 7 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/ImageIoFormat.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | enum class ImageIoFormat { 4 | LosslessWebPImageIoFormat, 5 | ImageIoFormat 6 | } -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/Navigation.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | enum class Navigation { 4 | NONAV, 5 | DPAD, 6 | TRACKBALL, 7 | WHEEL, 8 | } 9 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/ScreenSize.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | enum class ScreenSize { 4 | SMALL, 5 | NORMAL, 6 | LARGE, 7 | XLARGE, 8 | } 9 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/ScreenAspect.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | enum class ScreenAspect(val qualifier: String) { 4 | LONG("long"), 5 | NOTLONG("notlong"), 6 | } 7 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/RoundScreen.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | enum class RoundScreen(val qualifier: String) { 4 | ROUND("round"), 5 | NOTROUND("notround"), 6 | } 7 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/ScreenOrientation.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | enum class ScreenOrientation(val qualifier: String){ 4 | PORTRAIT("port"), 5 | LANDSCAPE("land"), 6 | } 7 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/ScreenType.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | enum class ScreenType(val qualifier: String) { 4 | WATCH("watch"), 5 | TV("television"), 6 | CAR("car"), 7 | } 8 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/RoundScreen.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | enum class RoundScreen(val qualifier: String) { 4 | ROUND("round"), 5 | NOTROUND("notround"), 6 | } 7 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/ScreenAspect.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | enum class ScreenAspect(val qualifier: String) { 4 | LONG("long"), 5 | NOTLONG("notlong"), 6 | } 7 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/Condition.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules 2 | 3 | /** 4 | * Taken from : https://github.com/novoda/espresso-support 5 | */ 6 | interface Condition { 7 | 8 | fun holds(): Boolean 9 | } 10 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/uiMode/UiModeSetter.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.uiMode 2 | 3 | import sergio.sastre.uitesting.utils.common.UiMode 4 | 5 | interface UiModeSetter { 6 | fun setUiModeDuringTestOnly(uiMode: UiMode) 7 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Feb 02 06:47:34 CET 2022 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/ScreenOrientation.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | enum class ScreenOrientation(val qualifier: String){ 4 | PORTRAIT("port"), 5 | LANDSCAPE("land"), 6 | } 7 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/ScreenType.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | enum class ScreenType(val qualifier: String) { 4 | WATCH("watch"), 5 | TV("television"), 6 | CAR("car"), 7 | } 8 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/ScreenSize.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | enum class ScreenSize(val qualifier: String){ 4 | SMALL("small"), 5 | NORMAL("normal"), 6 | LARGE("large"), 7 | XLARGE("xlarge"), 8 | } 9 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/config/LibraryConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.config 2 | 3 | /** 4 | * Marker interface to ensure that each library defines its own configuration, and not any value 5 | * is admitted 6 | */ 7 | interface LibraryConfig 8 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/AiAssertion.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | data class AiAssertion( 4 | val assertionPrompt: String, 5 | val requiredFulfillmentPercent: Int, 6 | val failIfNotFulfilled: Boolean = true 7 | ) 8 | 9 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/ScreenSize.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | enum class ScreenSize(val qualifier: String){ 4 | SMALL("small"), 5 | NORMAL("normal"), 6 | LARGE("large"), 7 | XLARGE("xlarge"), 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/Orientation.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | import android.content.pm.ActivityInfo 4 | 5 | enum class Orientation(val activityInfo: Int) { 6 | PORTRAIT(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), 7 | LANDSCAPE(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); 8 | } 9 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/RenderingMode.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | enum class RenderingMode{ 4 | NORMAL, 5 | V_SCROLL, 6 | H_SCROLL, 7 | FULL_EXPAND, 8 | // Shrink canvas to the minimum size that is needed to cover the scene 9 | SHRINK 10 | } 11 | -------------------------------------------------------------------------------- /shot/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/FontWeight.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | enum class FontWeight( 4 | val value: Int 5 | ) { 6 | THIN(-300), 7 | EXTRA_LIGHT(-200), 8 | LIGHT(-100), 9 | NORMAL(0), 10 | MEDIUM(100), 11 | SEMI_BOLD(200), 12 | BOLD(300), 13 | EXTRA_BOLD(400), 14 | BLACK(500), 15 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/config/BitmapCaptureMethod.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.config 2 | 3 | import android.graphics.Bitmap 4 | 5 | sealed class BitmapCaptureMethod { 6 | class PixelCopy(val config: Bitmap.Config = Bitmap.Config.ARGB_8888): BitmapCaptureMethod() 7 | class Canvas(val config: Bitmap.Config = Bitmap.Config.ARGB_8888): BitmapCaptureMethod() 8 | } 9 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/RecordOptions.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | data class RecordOptions( 4 | val resizeScale: Double = 5 | checkNotNull(System.getProperty("roborazzi.record.resizeScale", "1.0")).toDouble(), 6 | val applyDeviceCrop: Boolean = false, 7 | val imageIoFormat: ImageIoFormat = ImageIoFormat.ImageIoFormat, 8 | ) 9 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioConfiguratorExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import android.app.Activity 4 | 5 | inline fun activityScenarioForActivityRule( 6 | config: ActivityConfigItem? = null, 7 | ): ActivityScenarioForActivityRule = ActivityScenarioForActivityRule( 8 | T::class.java, 9 | config, 10 | ) 11 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/UiMode.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | import android.content.res.Configuration 4 | import androidx.appcompat.app.AppCompatDelegate 5 | 6 | enum class UiMode(val appCompatDelegateInt: Int, val configurationInt: Int) { 7 | NIGHT(AppCompatDelegate.MODE_NIGHT_YES, Configuration.UI_MODE_NIGHT_YES), 8 | DAY(AppCompatDelegate.MODE_NIGHT_NO, Configuration.UI_MODE_NIGHT_NO); 9 | } 10 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /robolectric/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | rootProject.name = "My Application" 9 | include ':app' 10 | include ':utils' 11 | include ':paparazzi' 12 | include ':shot' 13 | include ':mapper-paparazzi' 14 | include ':dropshots' 15 | include ':robolectric' 16 | include ':roborazzi' 17 | include ':mapper-roborazzi' 18 | include ':android-testify' 19 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/CaptureType.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.DumpExplanation.DefaultExplanation 4 | 5 | sealed interface CaptureType { 6 | data object Screenshot : CaptureType 7 | data class Dump(val explanation: DumpExplanation = DefaultExplanation) : CaptureType 8 | } 9 | 10 | enum class DumpExplanation { 11 | AccessibilityExplanation, 12 | DefaultExplanation, 13 | } 14 | -------------------------------------------------------------------------------- /shot/src/main/java/sergio/sastre/uitesting/shot/ActivityScenarioForComposableRuleExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.shot 2 | 3 | import androidx.activity.compose.setContent 4 | import androidx.compose.runtime.Composable 5 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioForComposableRule 6 | 7 | fun ActivityScenarioForComposableRule.setContent( 8 | composable: @Composable () -> Unit, 9 | ): ActivityScenarioForComposableRule { 10 | this.activityScenario 11 | .onActivity { 12 | it.setContent { composable.invoke() } 13 | } 14 | 15 | return this 16 | } 17 | -------------------------------------------------------------------------------- /dropshots/src/main/java/sergio/sastre/uitesting/dropshots/ActivityScenarioForComposableRuleExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.dropshots 2 | 3 | import androidx.activity.compose.setContent 4 | import androidx.compose.runtime.Composable 5 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioForComposableRule 6 | 7 | fun ActivityScenarioForComposableRule.setContent( 8 | composable: @Composable () -> Unit, 9 | ): ActivityScenarioForComposableRule { 10 | this.activityScenario 11 | .onActivity { 12 | it.setContent { composable.invoke() } 13 | } 14 | 15 | return this 16 | } 17 | -------------------------------------------------------------------------------- /android-testify/src/main/java/sergio/sastre/uitesting/android_testify/screenshotscenario/ActivityScenarioForComposableRuleExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.android_testify.screenshotscenario 2 | 3 | import androidx.activity.compose.setContent 4 | import androidx.compose.runtime.Composable 5 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioForComposableRule 6 | 7 | fun ActivityScenarioForComposableRule.setContent( 8 | composable: @Composable () -> Unit, 9 | ): ActivityScenarioForComposableRule { 10 | this.activityScenario 11 | .onActivity { 12 | it.setContent { composable.invoke() } 13 | } 14 | 15 | return this 16 | } 17 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/activityscenario/RobolectricActivityScenarioConfiguratorExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.activityscenario 2 | 3 | import android.app.Activity 4 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 5 | import sergio.sastre.uitesting.utils.activityscenario.ActivityConfigItem 6 | 7 | inline fun robolectricActivityScenarioForActivityRule( 8 | deviceScreen: DeviceScreen? = null, 9 | config: ActivityConfigItem? = null, 10 | ): RobolectricActivityScenarioForActivityRule = RobolectricActivityScenarioForActivityRule( 11 | T::class.java, 12 | deviceScreen, 13 | config, 14 | ) 15 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/view/TestDataForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.view 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ViewConfigItem 5 | 6 | data class TestDataForView>( 7 | val uiState: T, 8 | val device: DeviceScreen? = null, 9 | val config: ViewConfigItem? = null 10 | ){ 11 | val screenshotId: String 12 | get() = listOfNotNull( 13 | uiState.name, 14 | config?.id, 15 | device?.name 16 | ) 17 | .filter { it.isNotBlank() } 18 | .joinToString(separator = "_") 19 | } 20 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/DisplaySize.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | enum class DisplaySize(val value: String) { 4 | SMALL(0.85f.toString()), 5 | NORMAL(1f.toString()), 6 | LARGE(1.1f.toString()), 7 | LARGER(1.2f.toString()), 8 | LARGEST(1.3f.toString()); 9 | 10 | companion object { 11 | @JvmStatic 12 | fun from(scale: Float): DisplaySize { 13 | for (displaySize in values()) { 14 | if (displaySize.value == scale.toString()) { 15 | return displaySize 16 | } 17 | } 18 | throw IllegalArgumentException("Unknown display scale: $scale") 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/activity/TestDataForActivity.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.activity 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ActivityConfigItem 5 | 6 | data class TestDataForActivity>( 7 | val uiState: T, 8 | val device: DeviceScreen? = null, 9 | val config: ActivityConfigItem? = null 10 | ){ 11 | val screenshotId: String 12 | get() = listOfNotNull( 13 | uiState.name, 14 | config?.id, 15 | device?.name 16 | ) 17 | .filter { it.isNotBlank() } 18 | .joinToString(separator = "_") 19 | } 20 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/fragment/TestDataForFragment.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.fragment 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.fragmentscenario.FragmentConfigItem 5 | 6 | data class TestDataForFragment>( 7 | val uiState: T, 8 | val device: DeviceScreen? = null, 9 | val config: FragmentConfigItem? = null 10 | ){ 11 | val screenshotId: String 12 | get() = listOfNotNull( 13 | uiState.name, 14 | config?.id, 15 | device?.name 16 | ) 17 | .filter { it.isNotBlank() } 18 | .joinToString(separator = "_") 19 | } 20 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/composable/TestDataForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.composable 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ComposableConfigItem 5 | 6 | data class TestDataForComposable>( 7 | val uiState: T, 8 | val device: DeviceScreen? = null, 9 | val config: ComposableConfigItem? = null 10 | ) { 11 | val screenshotId: String 12 | get() = listOfNotNull( 13 | uiState.name, 14 | config?.id, 15 | device?.name 16 | ) 17 | .filter { it.isNotBlank() } 18 | .joinToString(separator = "_") 19 | } 20 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/ScreenshotTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.junit.rules.TestWatcher 5 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 6 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 7 | 8 | abstract class ScreenshotTestRuleForComposable( 9 | open val config: ScreenshotConfigForComposable, 10 | ): TestWatcher() { 11 | abstract fun snapshot(composable: @Composable () -> Unit) 12 | abstract fun snapshot(name: String? = null, composable: @Composable () -> Unit) 13 | abstract fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable 14 | } 15 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/Density.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | interface DpiDensity { 4 | val dpi: Int 5 | class Value(override val dpi: Int) : DpiDensity 6 | } 7 | 8 | enum class Density(override val dpi: Int) : DpiDensity { 9 | XXXHIGH(640), 10 | DPI_560(560), 11 | XXHIGH(480), 12 | DPI_440(440), 13 | DPI_420(420), 14 | DPI_400(400), 15 | DPI_360(360), 16 | XHIGH(480), 17 | DPI_260(260), 18 | DPI_280(280), 19 | DPI_300(300), 20 | DPI_340(340), 21 | HIGH(240), 22 | DPI_220(220), 23 | TV(213), 24 | DPI_200(200), 25 | DPI_180(180), 26 | MEDIUM(160), 27 | DPI_140(140), 28 | LOW(120), 29 | ANYDPI(0xFFFE), 30 | NODPI(0xFFFF) 31 | } 32 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/RoborazziOptions.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | /** 4 | * WARNING: 5 | * @param aiAssertions requires you to add the corresponding Roborazzi module, namely one of these: 6 | * 1. roborazzi-ai-gemini -> testImplementation(io.github.takahirom.roborazzi:roborazzi-ai-gemini) 7 | * 2. roborazzi-ai-openai -> testImplementation(io.github.takahirom.roborazzi:roborazzi-ai-openai) 8 | */ 9 | data class RoborazziOptions( 10 | val captureType: CaptureType = CaptureType.Screenshot, 11 | val compareOptions: CompareOptions = CompareOptions(), 12 | val recordOptions: RecordOptions = RecordOptions(), 13 | val contextData: Map = emptyMap(), 14 | val aiAssertions: List = emptyList() 15 | ) 16 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /shot/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /utils/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /dropshots/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /paparazzi/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /robolectric/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /roborazzi/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /shot/src/main/java/sergio/sastre/uitesting/shot/ShotConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.shot 2 | 3 | import androidx.annotation.ColorInt 4 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 5 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 6 | 7 | /** 8 | * @param viewMaxPixels: Max Pixels for the generated screenshot before throwing an exception. 9 | * WARNING: 10 | * 1. Requires bitmapCaptureMethod = null 11 | * 2. Works only with Views, not with Composables 12 | */ 13 | data class ShotConfig( 14 | val ignoredViews: List = emptyList(), 15 | val prepareUIForScreenshot: () -> Unit = {}, 16 | val bitmapCaptureMethod: BitmapCaptureMethod? = null, 17 | @get:ColorInt val backgroundColor: Int? = null, 18 | val viewMaxPixels: Long = 10_000_000L, 19 | ): LibraryConfig 20 | -------------------------------------------------------------------------------- /android-testify/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /mapper-paparazzi/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /mapper-roborazzi/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/sergio/sastre/uitesting/myapplication/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.myapplication 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("sergio.sastre.uitesting.myapplication", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /dropshots/src/main/java/sergio/sastre/uitesting/dropshots/DropshotsConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.dropshots 2 | 3 | import androidx.annotation.ColorInt 4 | import com.dropbox.differ.ImageComparator 5 | import com.dropbox.differ.SimpleImageComparator 6 | import com.dropbox.dropshots.CountValidator 7 | import com.dropbox.dropshots.ResultValidator 8 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | 11 | data class DropshotsConfig( 12 | val resultValidator: ResultValidator = CountValidator(0), 13 | val imageComparator: ImageComparator = SimpleImageComparator(maxDistance = 0.004f), 14 | val bitmapCaptureMethod: BitmapCaptureMethod? = null, 15 | @get:ColorInt val backgroundColor: Int? = null, 16 | val filePath: String? = null, 17 | ) : LibraryConfig 18 | -------------------------------------------------------------------------------- /utils/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /roborazzi/src/main/java/sergio/sastre/uitesting/roborazzi/FilePathGenerator.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.roborazzi 2 | 3 | import com.github.takahirom.roborazzi.DefaultFileNameGenerator 4 | import com.github.takahirom.roborazzi.InternalRoborazziApi 5 | import java.io.File 6 | 7 | internal class FilePathGenerator { 8 | 9 | operator fun invoke(parent: String, fileName: String?): String { 10 | val fileNameWithExtension = 11 | when (fileName != null) { 12 | true -> "$fileName.png" 13 | false -> generateFilePath() 14 | } 15 | 16 | return File(parent, fileNameWithExtension).path 17 | } 18 | 19 | @OptIn(InternalRoborazziApi::class) 20 | private fun generateFilePath(): String { 21 | val defaultFileName = DefaultFileNameGenerator.generateFilePath("png") 22 | return defaultFileName.split("/").lastOrNull() ?: defaultFileName 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/CompareOptions.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper 2 | 3 | data class CompareOptions( 4 | val changeThreshold: Float = 0.0F, 5 | val outputDirectoryPath: String = DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH, 6 | val comparisonStyle: ComparisonStyle = ComparisonStyle.Grid(), 7 | val simpleImageComparator: SimpleImageComparator = SimpleImageComparator() 8 | ) 9 | 10 | class SimpleImageComparator( 11 | val maxDistance: Float = 0.007F, 12 | val hShift: Int = 0, 13 | val vShift: Int = 0, 14 | ) 15 | 16 | sealed interface ComparisonStyle { 17 | data class Grid( 18 | val bigLineSpaceDp: Int? = 16, 19 | val smallLineSpaceDp: Int? = 4, 20 | val hasLabel: Boolean = true 21 | ) : ComparisonStyle 22 | 23 | data object Simple : ComparisonStyle 24 | } 25 | 26 | const val DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH = "build/outputs/roborazzi" 27 | -------------------------------------------------------------------------------- /roborazzi/src/main/java/sergio/sastre/uitesting/roborazzi/DrawToBitmapExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.roborazzi 2 | 3 | import android.app.Dialog 4 | import android.graphics.Bitmap 5 | import android.view.View 6 | import androidx.core.view.drawToBitmap 7 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 8 | import sergio.sastre.uitesting.utils.utils.drawToBitmapWithElevation 9 | 10 | internal fun View.drawToBitmap( 11 | bitmapCaptureMethod: BitmapCaptureMethod? 12 | ): Bitmap = 13 | when (bitmapCaptureMethod){ 14 | is BitmapCaptureMethod.Canvas -> drawToBitmap(config = bitmapCaptureMethod.config) 15 | is BitmapCaptureMethod.PixelCopy -> drawToBitmapWithElevation(config = bitmapCaptureMethod.config) 16 | null -> drawToBitmapWithElevation() 17 | } 18 | 19 | internal fun Dialog.drawToBitmap( 20 | bitmapCaptureMethod: BitmapCaptureMethod?, 21 | ): Bitmap = 22 | window!!.decorView.drawToBitmap(bitmapCaptureMethod) -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/locale/OnActivityCreatedCallback.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.locale 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.os.Bundle 6 | 7 | internal interface OnActivityCreatedCallback: Application.ActivityLifecycleCallbacks { 8 | 9 | override fun onActivityStarted(activity: Activity) { 10 | // no-op 11 | } 12 | 13 | override fun onActivityResumed(activity: Activity) { 14 | // no-op 15 | } 16 | 17 | override fun onActivityPaused(activity: Activity) { 18 | // no-op 19 | } 20 | 21 | override fun onActivityStopped(activity: Activity) { 22 | // no-op 23 | } 24 | 25 | override fun onActivitySaveInstanceState( 26 | activity: Activity, 27 | outState: Bundle 28 | ) { 29 | // no-op 30 | } 31 | 32 | override fun onActivityDestroyed(activity: Activity) { 33 | // no-op 34 | } 35 | } -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/activityscenario/OnActivityCreatedCallback.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.activityscenario 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.os.Bundle 6 | 7 | internal interface OnActivityCreatedCallback: Application.ActivityLifecycleCallbacks { 8 | 9 | override fun onActivityStarted(activity: Activity) { 10 | // no-op 11 | } 12 | 13 | override fun onActivityResumed(activity: Activity) { 14 | // no-op 15 | } 16 | 17 | override fun onActivityPaused(activity: Activity) { 18 | // no-op 19 | } 20 | 21 | override fun onActivityStopped(activity: Activity) { 22 | // no-op 23 | } 24 | 25 | override fun onActivitySaveInstanceState( 26 | activity: Activity, 27 | outState: Bundle 28 | ) { 29 | // no-op 30 | } 31 | 32 | override fun onActivityDestroyed(activity: Activity) { 33 | // no-op 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/wrapper/screen/ScreenDensity.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen 2 | 3 | interface DpiDensity { 4 | val dpi: Int 5 | class Value(override val dpi: Int) : DpiDensity 6 | } 7 | 8 | enum class ScreenDensity(val qualifier: String, override val dpi: Int): DpiDensity { 9 | XXXHDPI("xxxhdpi", 640), 10 | DPI_560("560dpi", 560), 11 | XXHDPI("xxhdpi", 480), 12 | DPI_440("440dpi", 440), 13 | DPI_420("420dpi", 420), 14 | DPI_400("400dpi", 400), 15 | DPI_360("360dpi", 360), 16 | XHDPI("xhdpi", 480), 17 | DPI_260("260dpi", 260), 18 | DPI_280("280dpi", 280), 19 | DPI_300("300dpi", 300), 20 | DPI_340("340dpi", 340), 21 | HDPI("hdpi", 240), 22 | DPI_220("220dpi", 220), 23 | TVDPI("tvdpi", 213), 24 | DPI_200("200dpi", 200), 25 | DPI_180("180dpi", 180), 26 | MDPI("mdpi", 160), 27 | DPI_140("140dpi", 140), 28 | LDPI("ldpi", 120), 29 | ANYDPI("anydpi", 0xFFFE), 30 | NODPI("nodpi", 0xFFFF), 31 | } 32 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/RenderExtension.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | import android.view.View 4 | 5 | interface RenderExtension { 6 | /** 7 | * Allows this extension to modify the view hierarchy represented by [contentView]. 8 | * 9 | * Returns the root view of the modified hierarchy. 10 | */ 11 | fun renderView(contentView: View): View 12 | } 13 | 14 | /** 15 | * This is used via reflection to support accessibility 16 | */ 17 | class AccessibilityRenderExtension : RenderExtension { 18 | override fun renderView(contentView: View): View { 19 | // use reflection to avoid direct dependency on Paparazzi 20 | val parameterType: Class<*> = View::class.java 21 | val clazz = Class.forName("app.cash.paparazzi.accessibility.AccessibilityRenderExtension") 22 | val method = clazz.getDeclaredMethod("renderView", parameterType) 23 | val instance = clazz.getDeclaredConstructor().newInstance() 24 | 25 | return method.invoke(instance, contentView) as View 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Sergio Sastre Flórez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Environment** 14 | The setup in which the bug is reproducible: 15 | - Device or Emulator: [e.g. Emulator] 16 | - API level: [e.g. API 26+, < API 26] 17 | - AndroidUiTestingUtils version: [e.g. 1.0.0] 18 | - Affected Component: [e.g. Activity, Composable, AndroidView] 19 | - Buggy Configuration: [e.g. Locale, FontSize, Orientation, Dark Mode] 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Reproducible sample** 25 | If applicable, create a small repo where the problem is reproducible as well as a link to it. You can also fork from [Road to effective snapshot testing](https://github.com/sergio-sastre/Road-To-Effective-Snapshot-Testing). That repo has already a working [pedrovgs/Shot](https://github.com/pedrovgs/Shot) configuration in place. 26 | 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/orientation/OrientationTestWatcher.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario.orientation 2 | 3 | import android.app.Activity 4 | import androidx.test.core.app.ActivityScenario 5 | import org.junit.rules.TestWatcher 6 | import sergio.sastre.uitesting.utils.common.Orientation 7 | 8 | class OrientationTestWatcher(private val orientation: Orientation?) : TestWatcher() { 9 | 10 | var activityScenario: ActivityScenario? = null 11 | set(value) { 12 | field = value 13 | field?.setOrientation(orientation) 14 | } 15 | 16 | private fun ActivityScenario.setOrientation(orientation: Orientation?) 17 | : ActivityScenario { 18 | var orientationHelper: OrientationHelper? = null 19 | val activityScenario = this.onActivity { 20 | orientation?.run { 21 | orientationHelper = OrientationHelper(it) 22 | orientationHelper?.requestedOrientation = this.activityInfo 23 | } 24 | } 25 | orientationHelper?.setLayoutOrientation() 26 | return activityScenario 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/screen/ScreenDensity.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config.screen 2 | 3 | interface DpiDensity { 4 | val dpi: Int 5 | class Value(override val dpi: Int) : DpiDensity 6 | 7 | fun valueAsQualifier(): String = 8 | when (this is ScreenDensity) { 9 | true -> this.qualifier 10 | false -> this.dpi.toString() + "dpi" 11 | } 12 | } 13 | 14 | enum class ScreenDensity(val qualifier: String, override val dpi: Int): DpiDensity { 15 | XXXHDPI("xxxhdpi", 640), 16 | DPI_560("560dpi", 560), 17 | XXHDPI("xxhdpi", 480), 18 | DPI_440("440dpi", 440), 19 | DPI_420("420dpi", 420), 20 | DPI_400("400dpi", 400), 21 | DPI_360("360dpi", 360), 22 | XHDPI("xhdpi", 480), 23 | DPI_260("260dpi", 260), 24 | DPI_280("280dpi", 280), 25 | DPI_300("300dpi", 300), 26 | DPI_340("340dpi", 340), 27 | HDPI("hdpi", 240), 28 | DPI_220("220dpi", 220), 29 | TVDPI("tvdpi", 213), 30 | DPI_200("200dpi", 200), 31 | DPI_180("180dpi", 180), 32 | MDPI("mdpi", 160), 33 | DPI_140("140dpi", 140), 34 | LDPI("ldpi", 120), 35 | ANYDPI("anydpi", 0xFFFE), 36 | NODPI("nodpi", 0xFFFF), 37 | } 38 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/LocaleListCompat.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | /** 4 | * Code from fastlane Screengrab 5 | * https://github.com/fastlane/fastlane/blob/master/screengrab/screengrab-lib/src/main/java/tools.fastlane.screengrab/locale 6 | * 7 | * The code was converted to Kotlin 8 | */ 9 | import android.os.LocaleList 10 | import android.os.Build 11 | import androidx.annotation.RequiresApi 12 | import java.util.* 13 | 14 | class LocaleListCompat { 15 | var locale: Locale? = null 16 | private set 17 | 18 | @get:RequiresApi(Build.VERSION_CODES.N) 19 | var localeList: LocaleList? = null 20 | private set 21 | 22 | constructor(locale: Locale?) { 23 | this.locale = locale 24 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) localeList = LocaleList(locale) 25 | } 26 | 27 | @RequiresApi(Build.VERSION_CODES.N) 28 | constructor(localeList: LocaleList?) { 29 | this.localeList = localeList 30 | } 31 | 32 | val preferredLocale: Locale? 33 | get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && localeList != null) { 34 | localeList!![0] 35 | } else { 36 | locale 37 | } 38 | } -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/wrapper/Environment.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi.wrapper 2 | 3 | import java.util.* 4 | 5 | data class Environment( 6 | val compileSdkVersion: Int? = null, 7 | val appTestDir: String? = null, 8 | val packageName: String? = null, 9 | val resourcePackageNames: List? = null, 10 | val localResourceDirs: List? = null, 11 | val moduleResourceDirs : List? = null, 12 | val libraryResourceDirs : List? = null, 13 | val allModuleAssetDirs : List? = null, 14 | val libraryAssetDirs : List? = null, 15 | ) 16 | 17 | @Suppress("unused") 18 | fun androidHome() = System.getenv("ANDROID_SDK_ROOT") 19 | ?: System.getenv("ANDROID_HOME") 20 | ?: androidSdkPath() 21 | 22 | private fun androidSdkPath(): String { 23 | val osName = System.getProperty("os.name").lowercase(Locale.US) 24 | val sdkPathDir = if (osName.startsWith("windows")) { 25 | "\\AppData\\Local\\Android\\Sdk" 26 | } else if (osName.startsWith("mac")) { 27 | "/Library/Android/sdk" 28 | } else { 29 | "/Android/Sdk" 30 | } 31 | val homeDir = System.getProperty("user.home") 32 | return homeDir + sdkPathDir 33 | } 34 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'org.jetbrains.kotlin.android' 5 | } 6 | 7 | android { 8 | compileSdk 35 9 | 10 | defaultConfig { 11 | applicationId "sergio.sastre.uitesting.myapplication" 12 | minSdk 23 13 | 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = '1.8' 32 | } 33 | namespace 'sergio.sastre.uitesting.myapplication' 34 | } 35 | 36 | dependencies { 37 | implementation project(':utils') 38 | 39 | implementation 'com.google.android.material:material:1.13.0' 40 | testImplementation 'junit:junit:4.13.2' 41 | androidTestImplementation 'androidx.test.ext:junit:1.3.0' 42 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' 43 | } 44 | -------------------------------------------------------------------------------- /android-testify/src/main/java/sergio/sastre/uitesting/android_testify/AndroidTestifyConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.android_testify 2 | 3 | import android.graphics.Rect 4 | import androidx.annotation.ColorInt 5 | import dev.testify.CompareMethod 6 | import dev.testify.core.ExclusionRectProvider 7 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 8 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 9 | 10 | data class AndroidTestifyConfig( 11 | val bitmapCaptureMethod: BitmapCaptureMethod? = null, 12 | val exactness: Float = 0.9f, 13 | val enableReporter: Boolean = false, 14 | val generateDiffs: Boolean = true, 15 | val animationsDisabled: Boolean = true, 16 | val hideCursor: Boolean = true, 17 | val hidePasswords: Boolean = true, 18 | val hideScrollbars: Boolean = true, 19 | val hideSoftKeyboard: Boolean = true, 20 | val hideTextSuggestions: Boolean = true, 21 | val useSoftwareRenderer: Boolean = false, 22 | val exclusionRects: MutableSet = HashSet(), 23 | val exclusionRectProvider: ExclusionRectProvider? = null, 24 | val compareMethod: CompareMethod? = null, 25 | val pauseForInspection: Boolean = false, 26 | @get:ColorInt val backgroundColor: Int? = null, 27 | ) : LibraryConfig 28 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/ScreenshotTestRuleForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.view.View 6 | import androidx.annotation.LayoutRes 7 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 8 | import org.junit.rules.TestWatcher 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 11 | 12 | abstract class ScreenshotTestRuleForView( 13 | open val config: ScreenshotConfigForView, 14 | ): TestWatcher() { 15 | abstract val context: Context 16 | abstract fun inflate(@LayoutRes layoutId: Int): View 17 | 18 | abstract fun waitForMeasuredView(actionToDo: () -> View): View 19 | abstract fun waitForMeasuredDialog(actionToDo: () -> Dialog): Dialog 20 | abstract fun waitForMeasuredViewHolder(actionToDo: () -> ViewHolder): ViewHolder 21 | 22 | abstract fun snapshotDialog(name: String? = null, dialog: Dialog) 23 | abstract fun snapshotView(name: String? = null, view: View) 24 | abstract fun snapshotViewHolder(name: String? = null, viewHolder: ViewHolder) 25 | 26 | abstract fun configure(config: LibraryConfig): ScreenshotTestRuleForView 27 | } 28 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/uiMode/UiModeTestRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.uiMode 2 | 3 | import android.os.Build 4 | import org.junit.rules.TestRule 5 | import org.junit.runner.Description 6 | import org.junit.runners.model.Statement 7 | import sergio.sastre.uitesting.utils.common.UiMode 8 | 9 | /** 10 | * A TestRule to change the UiMode to day or night. 11 | * 12 | * It is set via Adb from API 30+. For older APIs, it uses AppCompatDelegate, based on Adevinta/Barista DayNightRule. 13 | * https://github.com/AdevintaSpain/Barista/tree/master/library%2Fsrc%2Fmain%2Fjava%2Fcom%2Fadevinta%2Fandroid%2Fbarista%2Frule%2Ftheme 14 | * 15 | * WARNING: It's not compatible with Robolectric 16 | */ 17 | class UiModeTestRule(private val mode: UiMode) : TestRule { 18 | 19 | override fun apply(base: Statement, description: Description): Statement { 20 | return object : Statement() { 21 | @Throws(Throwable::class) 22 | override fun evaluate() { 23 | when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 24 | true -> AdbUiModeSetter(base, description) 25 | false -> AppCompatDelegateUiModeSetter(base, description) 26 | }.setUiModeDuringTestOnly(mode) 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ComposableConfigItem.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import sergio.sastre.uitesting.utils.common.DisplaySize 4 | import sergio.sastre.uitesting.utils.common.FontSizeScale 5 | import sergio.sastre.uitesting.utils.common.FontWeight 6 | import sergio.sastre.uitesting.utils.common.Orientation 7 | import sergio.sastre.uitesting.utils.common.UiMode 8 | 9 | data class ComposableConfigItem( 10 | val orientation: Orientation? = null, 11 | val uiMode: UiMode? = null, 12 | val locale: String? = null, 13 | val fontSize: FontSizeScale? = null, 14 | val displaySize: DisplaySize? = null, 15 | val fontWeight: FontWeight? = null, 16 | ) { 17 | val id: String 18 | get() { 19 | val nonNullProperties = mutableListOf() 20 | locale?.let { nonNullProperties.add(it.uppercase()) } 21 | uiMode?.let { nonNullProperties.add(it.name) } 22 | fontSize?.let { nonNullProperties.add("FONT_${it.valueAsName()}") } 23 | fontWeight?.let { nonNullProperties.add("WEIGHT_${it.name}") } 24 | displaySize?.let { nonNullProperties.add("DISPLAY_${it.name}") } 25 | orientation?.let { nonNullProperties.add(it.name) } 26 | return nonNullProperties.joinToString(separator = "_") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityConfigItem.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import sergio.sastre.uitesting.utils.common.DisplaySize 4 | import sergio.sastre.uitesting.utils.common.FontSizeScale 5 | import sergio.sastre.uitesting.utils.common.FontWeight 6 | import sergio.sastre.uitesting.utils.common.Orientation 7 | import sergio.sastre.uitesting.utils.common.UiMode 8 | 9 | data class ActivityConfigItem( 10 | val orientation: Orientation? = null, 11 | val uiMode: UiMode? = null, 12 | val systemLocale: String? = null, 13 | val fontSize: FontSizeScale? = null, 14 | val displaySize: DisplaySize? = null, 15 | val fontWeight: FontWeight? = null, 16 | ) { 17 | val id: String 18 | get() { 19 | val nonNullProperties = mutableListOf() 20 | systemLocale?.let { nonNullProperties.add(it.uppercase()) } 21 | uiMode?.let { nonNullProperties.add(it.name) } 22 | fontSize?.let { nonNullProperties.add("FONT_${it.valueAsName()}") } 23 | fontWeight?.let { nonNullProperties.add("WEIGHT_${it.name}") } 24 | displaySize?.let { nonNullProperties.add("DISPLAY_${it.name}") } 25 | orientation?.let { nonNullProperties.add(it.name) } 26 | return nonNullProperties.joinToString(separator = "_") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mapper-roborazzi/src/main/java/sergio/sastre/uitesting/mapper/roborazzi/RoborazziConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.roborazzi 2 | 3 | import androidx.annotation.ColorInt 4 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.CaptureType 5 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.DumpExplanation.AccessibilityExplanation 6 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.RoborazziOptions 7 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.screen.DeviceScreen 8 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | 11 | data class RoborazziConfig( 12 | val filePath: String = DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH, 13 | val roborazziOptions: RoborazziOptions = RoborazziOptions(), 14 | val deviceScreen: DeviceScreen? = null, 15 | @get:ColorInt val backgroundColor: Int? = null, 16 | val bitmapCaptureMethod: BitmapCaptureMethod? = null, 17 | ) : LibraryConfig { 18 | 19 | fun overrideForDefaultAccessibility(): RoborazziConfig { 20 | return copy( 21 | roborazziOptions = roborazziOptions.copy( 22 | captureType = CaptureType.Dump(AccessibilityExplanation) 23 | ), 24 | ) 25 | } 26 | 27 | companion object { 28 | const val DEFAULT_ROBORAZZI_OUTPUT_DIR_PATH = "build/outputs/roborazzi" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | android.jetifier.ignorelist=android-base-common,common,bcprov 21 | # Kotlin code style for this project: "official" or "obsolete": 22 | kotlin.code.style=official 23 | android.defaults.buildfeatures.buildconfig=true 24 | android.nonTransitiveRClass=false 25 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/displaysize/DisplayScaleSetting.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.displaysize 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 4 | import sergio.sastre.uitesting.utils.common.DisplaySize 5 | import sergio.sastre.uitesting.utils.utils.waitForExecuteShellCommand 6 | 7 | class DisplayScaleSetting internal constructor() { 8 | 9 | private val resources 10 | get() = getInstrumentation().targetContext.resources 11 | 12 | val densityDpi: Int 13 | get() = resources.configuration.densityDpi 14 | 15 | fun resetDisplaySizeScale(originalDensity: Int) { 16 | try { 17 | getInstrumentation().waitForExecuteShellCommand("wm density reset") 18 | } catch (e: Exception) { 19 | throw RuntimeException("Unable to reset densityDpi to $originalDensity") 20 | } 21 | } 22 | 23 | fun setDisplaySizeScale(targetDensity: Int) { 24 | try { 25 | getInstrumentation().waitForExecuteShellCommand("wm density $targetDensity") 26 | } catch (e: Exception) { 27 | throw RuntimeException("Unable to set display size with density $targetDensity") 28 | } 29 | } 30 | 31 | fun setDisplaySizeScale(scale: DisplaySize) { 32 | val targetDensity = densityDpi * (scale.value).toFloat() 33 | setDisplaySizeScale(targetDensity.toInt()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/ContextExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | import android.os.Build 6 | import android.util.Log 7 | import sergio.sastre.uitesting.utils.common.DisplaySize 8 | import sergio.sastre.uitesting.utils.common.FontWeight 9 | 10 | @Suppress("DEPRECATION") 11 | internal fun Context.setDisplaySize(displaySize: DisplaySize) = 12 | this.apply { 13 | val density = resources.configuration.densityDpi 14 | val config = Configuration(resources.configuration) 15 | config.densityDpi = (density * displaySize.value.toFloat()).toInt() 16 | resources.updateConfiguration(config, resources.displayMetrics) 17 | } 18 | 19 | @Suppress("DEPRECATION") 20 | internal fun Context.setFontWeight(fontWeight: FontWeight) = 21 | this.apply { 22 | when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 23 | true -> { 24 | val config = Configuration(resources.configuration) 25 | config.fontWeightAdjustment = fontWeight.value 26 | resources.updateConfiguration(config, resources.displayMetrics) 27 | } 28 | 29 | false -> Log.d( 30 | this.javaClass.simpleName, 31 | "Skipping FontWeightAdjustment. It can only be used on API 31+, and the current API is ${Build.VERSION.SDK_INT}" 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mapper-paparazzi/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.mapper.paparazzi' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | publishing { 34 | singleVariant("release") { 35 | withSourcesJar() 36 | withJavadocJar() 37 | } 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation project(':utils') 43 | } 44 | 45 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 46 | publishing { 47 | publications { 48 | release(MavenPublication) { 49 | groupId = 'com.github.sergio-sastre' 50 | artifactId = "mapper-paparazzi" 51 | version = '2.8.0' 52 | 53 | afterEvaluate { 54 | from components.release 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /mapper-roborazzi/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.mapper.roborazzi' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | publishing { 34 | singleVariant("release") { 35 | withSourcesJar() 36 | withJavadocJar() 37 | } 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation project(':utils') 43 | } 44 | 45 | 46 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 47 | publishing { 48 | publications { 49 | release(MavenPublication) { 50 | groupId = 'com.github.sergio-sastre' 51 | artifactId = "mapper-roborazzi" 52 | version = '2.8.0' 53 | 54 | afterEvaluate { 55 | from components.release 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /robolectric/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.robolectric' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | publishing { 34 | singleVariant("release") { 35 | withSourcesJar() 36 | withJavadocJar() 37 | } 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation project(':utils') 43 | implementation "org.robolectric:robolectric:4.16" 44 | } 45 | 46 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 47 | publishing { 48 | publications { 49 | release(MavenPublication) { 50 | groupId = 'com.github.sergio-sastre' 51 | artifactId = "robolectric" 52 | version = '2.8.0' 53 | 54 | afterEvaluate { 55 | from components.release 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/accessibility/HighTextContrastTestRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.accessibility 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 4 | import org.junit.rules.TestRule 5 | import org.junit.runner.Description 6 | import org.junit.runners.model.Statement 7 | import sergio.sastre.uitesting.utils.utils.waitForExecuteShellCommand 8 | import java.io.IOException 9 | 10 | /** 11 | * Allows to enable/disable high text contrast accessibility during a test. 12 | * 13 | * WARNING: Only works on Instrumentation tests, not on Robolectric tests. 14 | */ 15 | class HighTextContrastTestRule: TestRule { 16 | override fun apply(base: Statement, description: Description): Statement { 17 | return object : Statement() { 18 | @Throws(Throwable::class) 19 | override fun evaluate() { 20 | try { 21 | enableHighTextContrast(true) 22 | base.evaluate() 23 | } finally { 24 | enableHighTextContrast(false) 25 | } 26 | } 27 | } 28 | } 29 | 30 | @Throws(IOException::class) 31 | private fun enableHighTextContrast(enable: Boolean = true) { 32 | val highTextContrastEnabledValue = when (enable) { 33 | true -> 1 34 | false -> 0 35 | } 36 | getInstrumentation().waitForExecuteShellCommand("settings put secure high_text_contrast_enabled $highTextContrastEnabledValue") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/fragmentscenario/FragmentScenarioConfiguratorExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.fragmentscenario 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | import androidx.fragment.app.FragmentFactory 6 | import androidx.lifecycle.Lifecycle 7 | 8 | /** 9 | * Returns a [Fragment] from a [FragmentScenarioConfigurator]. 10 | */ 11 | fun FragmentScenarioConfigurator.waitForFragment(): A { 12 | var fragment: A? = null 13 | onFragment { 14 | fragment = it 15 | } 16 | return if (fragment != null) { 17 | fragment!! 18 | } else { 19 | throw IllegalStateException("The fragment scenario could not be initialized.") 20 | } 21 | } 22 | 23 | inline fun FragmentScenarioConfigurator.Companion.launchInContainer( 24 | fragmentArgs: Bundle? = null, 25 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 26 | factory: FragmentFactory? = null 27 | ): FragmentScenarioConfigurator = launchInContainer( 28 | T::class.java, 29 | fragmentArgs, 30 | initialState, 31 | factory, 32 | ) 33 | 34 | inline fun fragmentScenarioConfiguratorRule( 35 | fragmentArgs: Bundle? = null, 36 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 37 | factory: FragmentFactory? = null, 38 | config: FragmentConfigItem? = null, 39 | ): FragmentScenarioConfiguratorRule = FragmentScenarioConfiguratorRule( 40 | T::class.java, 41 | fragmentArgs, 42 | initialState, 43 | factory, 44 | config, 45 | ) 46 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/fragmentscenario/FragmentScenarioConfiguratorRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.fragmentscenario 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | import androidx.fragment.app.FragmentFactory 6 | import androidx.lifecycle.Lifecycle 7 | import org.junit.rules.ExternalResource 8 | 9 | class FragmentScenarioConfiguratorRule( 10 | fragmentClass: Class, 11 | fragmentArgs: Bundle?, 12 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 13 | factory: FragmentFactory? = null, 14 | config: FragmentConfigItem? = null, 15 | ) : ExternalResource() { 16 | 17 | val fragmentScenario = 18 | FragmentScenarioConfigurator.apply { 19 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 20 | config?.locale?.also { locale -> setLocale(locale) } 21 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 22 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 23 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 24 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 25 | config?.theme?.also { theme -> setTheme(theme) } 26 | }.launchInContainer(fragmentClass, fragmentArgs, initialState, factory) 27 | 28 | val fragment: Fragment by lazy { 29 | fragmentScenario.waitForFragment() 30 | } 31 | 32 | override fun after() { 33 | fragmentScenario.close() 34 | } 35 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/animations/DisableAnimationsTestRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.animations 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 4 | import org.junit.rules.TestRule 5 | import org.junit.runner.Description 6 | import org.junit.runners.model.Statement 7 | import sergio.sastre.uitesting.utils.utils.waitForExecuteShellCommand 8 | import java.io.IOException 9 | 10 | class DisableAnimationsRule : TestRule { 11 | override fun apply(base: Statement, description: Description): Statement { 12 | return object : Statement() { 13 | @Throws(Throwable::class) 14 | override fun evaluate() { 15 | try { 16 | setAnimations(enable = false) 17 | base.evaluate() 18 | } finally { 19 | setAnimations(enable = true) 20 | } 21 | } 22 | } 23 | } 24 | 25 | @Throws(IOException::class) 26 | private fun setAnimations(enable: Boolean = true) { 27 | val animationEnabledValue = when (enable) { 28 | true -> 1 29 | false -> 0 30 | } 31 | getInstrumentation().run { 32 | waitForExecuteShellCommand("settings put global transition_animation_scale $animationEnabledValue") 33 | waitForExecuteShellCommand("settings put global window_animation_scale $animationEnabledValue") 34 | waitForExecuteShellCommand("settings put global animator_duration_scale $animationEnabledValue") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/uiMode/AppCompatDelegateUiModeSetter.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.uiMode 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import android.util.Log 6 | import androidx.appcompat.app.AppCompatDelegate 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | import sergio.sastre.uitesting.utils.common.UiMode 10 | 11 | internal class AppCompatDelegateUiModeSetter( 12 | val base: Statement, 13 | val description: Description 14 | ): UiModeSetter { 15 | 16 | companion object { 17 | private val TAG = AppCompatDelegateUiModeSetter::class.java.simpleName 18 | } 19 | 20 | override fun setUiModeDuringTestOnly(uiMode: UiMode){ 21 | var initialUiMode = UiMode.DAY.appCompatDelegateInt 22 | try { 23 | Handler(Looper.getMainLooper()).post { 24 | initialUiMode = AppCompatDelegate.getDefaultNightMode() 25 | AppCompatDelegate.setDefaultNightMode(uiMode.appCompatDelegateInt) 26 | } 27 | base.evaluate() 28 | } catch (throwable: Throwable) { 29 | val testName = "${description.testClass.simpleName}\$${description.methodName}" 30 | val errorMessage = 31 | "Test $testName failed on setting uiMode to ${uiMode.name}" 32 | Log.e(TAG, errorMessage) 33 | throw throwable 34 | } finally { 35 | Handler(Looper.getMainLooper()).post { 36 | AppCompatDelegate.setDefaultNightMode(initialUiMode) 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioForViewRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import android.app.Activity 4 | import android.view.View 5 | import androidx.annotation.ColorInt 6 | import androidx.annotation.LayoutRes 7 | import org.junit.rules.ExternalResource 8 | import sergio.sastre.uitesting.utils.utils.inflateAndWaitForIdle 9 | import sergio.sastre.uitesting.utils.utils.waitForActivity 10 | 11 | class ActivityScenarioForViewRule( 12 | config: ViewConfigItem? = null, 13 | @ColorInt backgroundColor: Int? = null, 14 | ) : ExternalResource() { 15 | 16 | val activityScenario = 17 | ActivityScenarioConfigurator.ForView().apply { 18 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 19 | config?.locale?.also { locale -> setLocale(locale) } 20 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 21 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 22 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 23 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 24 | config?.theme?.also { theme -> setTheme(theme) } 25 | }.launchConfiguredActivity(backgroundColor) 26 | 27 | val activity: Activity by lazy { activityScenario.waitForActivity() } 28 | 29 | fun inflateAndWaitForIdle(@LayoutRes layoutId: Int, id: Int = android.R.id.content): View = 30 | activity.inflateAndWaitForIdle(layoutId, id) 31 | 32 | override fun after() { 33 | activityScenario.close() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/config/ScreenshotConfigForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.config 2 | 3 | import sergio.sastre.uitesting.utils.activityscenario.ComposableConfigItem 4 | import sergio.sastre.uitesting.utils.common.DisplaySize 5 | import sergio.sastre.uitesting.utils.common.FontSize 6 | import sergio.sastre.uitesting.utils.common.FontSizeScale 7 | import sergio.sastre.uitesting.utils.common.FontWeight 8 | import sergio.sastre.uitesting.utils.common.Orientation 9 | import sergio.sastre.uitesting.utils.common.UiMode 10 | 11 | /** 12 | * Configuration that all screenshot libraries support for cross-library screenshot tests. 13 | * 14 | * Warning: The locale should be in IEFT BCP 47 format: 15 | * language-extlang-script-region-variant-extension-privateuse 16 | * 17 | * For instance: 18 | * sr-Cyrl-RS // for Serbian written in Cyrillic 19 | * zh-Latn-TW-pinyin // for Chinese language spoken in Taiwan in pinyin 20 | * ca-ES-valencia // for Catalan spoken in Valencia 21 | * 22 | */ 23 | class ScreenshotConfigForComposable( 24 | val orientation: Orientation = Orientation.PORTRAIT, 25 | val uiMode: UiMode = UiMode.DAY, 26 | val fontScale: FontSizeScale = FontSize.NORMAL, 27 | val locale: String = "en", 28 | val displaySize: DisplaySize = DisplaySize.NORMAL, 29 | val fontWeight: FontWeight = FontWeight.NORMAL 30 | ) { 31 | 32 | fun toComposableConfig(): ComposableConfigItem = 33 | ComposableConfigItem( 34 | orientation = orientation, 35 | uiMode = uiMode, 36 | locale = locale, 37 | fontSize = fontScale, 38 | displaySize = displaySize, 39 | fontWeight = fontWeight, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/implementations/ScreenshotLibraryTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.implementations 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.junit.runner.Description 5 | import org.junit.runners.model.Statement 6 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 7 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 8 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 9 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.providers.ScreenshotLibraryTestRuleForComposableProvider 10 | 11 | abstract class ScreenshotLibraryTestRuleForComposable( 12 | override val config: ScreenshotConfigForComposable, 13 | ) : ScreenshotTestRuleForComposable(config), ScreenshotLibraryTestRuleForComposableProvider { 14 | 15 | private val factory: ScreenshotTestRuleForComposable by lazy { 16 | getScreenshotLibraryTestRule(config) 17 | } 18 | 19 | abstract fun getScreenshotLibraryTestRule(config: ScreenshotConfigForComposable): ScreenshotTestRuleForComposable 20 | 21 | override fun apply(base: Statement?, description: Description?): Statement { 22 | return factory.apply(base, description) 23 | } 24 | 25 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable { 26 | return factory.configure(config) 27 | } 28 | 29 | override fun snapshot(composable: @Composable () -> Unit) { 30 | factory.snapshot { composable() } 31 | } 32 | 33 | override fun snapshot(name: String?, composable: @Composable () -> Unit) { 34 | factory.snapshot(name) { composable() } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /paparazzi/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.paparazzi' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | sourceCompatibility JavaVersion.VERSION_1_8 25 | targetCompatibility JavaVersion.VERSION_1_8 26 | } 27 | kotlinOptions { 28 | jvmTarget = '1.8' 29 | } 30 | buildFeatures { 31 | // Enables Jetpack Compose for this module 32 | compose true 33 | } 34 | 35 | composeOptions { 36 | kotlinCompilerExtensionVersion '1.5.15' 37 | } 38 | 39 | publishing { 40 | singleVariant("release") { 41 | withSourcesJar() 42 | withJavadocJar() 43 | } 44 | } 45 | } 46 | 47 | dependencies { 48 | implementation project(':utils') 49 | api project(':mapper-paparazzi') 50 | api('app.cash.paparazzi:paparazzi:1.3.5') 51 | } 52 | 53 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 54 | publishing { 55 | publications { 56 | release(MavenPublication) { 57 | groupId = 'com.github.sergio-sastre' 58 | artifactId = "paparazzi" 59 | version = '2.8.0' 60 | 61 | afterEvaluate { 62 | from components.release 63 | } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/fragmentscenario/FragmentConfigItem.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.fragmentscenario 2 | 3 | import androidx.annotation.StyleRes 4 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 5 | import sergio.sastre.uitesting.utils.common.DisplaySize 6 | import sergio.sastre.uitesting.utils.common.FontSizeScale 7 | import sergio.sastre.uitesting.utils.common.FontWeight 8 | import sergio.sastre.uitesting.utils.common.Orientation 9 | import sergio.sastre.uitesting.utils.common.UiMode 10 | 11 | data class FragmentConfigItem( 12 | val orientation: Orientation? = null, 13 | val uiMode: UiMode? = null, 14 | val locale: String? = null, 15 | val fontSize: FontSizeScale? = null, 16 | val displaySize: DisplaySize? = null, 17 | @get:StyleRes val theme: Int? = null, 18 | val fontWeight: FontWeight? = null, 19 | ) { 20 | val id : String 21 | get() { 22 | val nonNullProperties = mutableListOf() 23 | locale?.let { nonNullProperties.add(it.uppercase()) } 24 | uiMode?.let { nonNullProperties.add(it.name) } 25 | theme?.let { 26 | val themeInfo = getInstrumentation().targetContext.resources.getResourceName(it) 27 | // The themeInfo will be in the format: "android:style/Theme.XXX" 28 | val theme = themeInfo.substring(themeInfo.lastIndexOf('/') + 1) 29 | nonNullProperties.add(theme.replace(".","_").uppercase()) 30 | } 31 | fontSize?.let { nonNullProperties.add("FONT_${it.valueAsName()}") } 32 | fontWeight?.let { nonNullProperties.add("WEIGHT_${it.name}") } 33 | displaySize?.let { nonNullProperties.add("DISPLAY_${it.name}") } 34 | orientation?.let { nonNullProperties.add(it.name) } 35 | return nonNullProperties.joinToString(separator = "_") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /dropshots/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.dropshots' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | buildFeatures { 34 | // Enables Jetpack Compose for this module 35 | compose true 36 | } 37 | 38 | composeOptions { 39 | kotlinCompilerExtensionVersion '1.5.15' 40 | } 41 | 42 | publishing { 43 | singleVariant("release") { 44 | withSourcesJar() 45 | withJavadocJar() 46 | } 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation project(':utils') 52 | implementation 'androidx.test:rules:1.7.0' 53 | api 'com.dropbox.dropshots:dropshots:0.5.1' 54 | api ("androidx.activity:activity-compose:1.9.3") { 55 | because "the last one requires compose option 1.6.x" 56 | } 57 | } 58 | 59 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 60 | publishing { 61 | publications { 62 | release(MavenPublication) { 63 | groupId = 'com.github.sergio-sastre' 64 | artifactId = "dropshots" 65 | version = '2.8.0' 66 | 67 | afterEvaluate { 68 | from components.release 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/config/PaparazziForViewTestRuleBuilder.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi.config 2 | 3 | import app.cash.paparazzi.Paparazzi 4 | import sergio.sastre.uitesting.mapper.paparazzi.PaparazziConfig 5 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 6 | 7 | class PaparazziForViewTestRuleBuilder { 8 | 9 | private var paparazziConfig: PaparazziConfig = PaparazziConfig() 10 | private var screenshotConfigForView: ScreenshotConfigForView = ScreenshotConfigForView() 11 | 12 | fun applyPaparazziConfig( 13 | paparazziConfig: PaparazziConfig, 14 | ): PaparazziForViewTestRuleBuilder = apply { 15 | this.paparazziConfig = paparazziConfig 16 | } 17 | 18 | fun applyScreenshotConfig( 19 | screenshotConfigForView: ScreenshotConfigForView, 20 | ): PaparazziForViewTestRuleBuilder = apply { 21 | this.screenshotConfigForView = screenshotConfigForView 22 | } 23 | 24 | fun build(): Paparazzi { 25 | val configAdapter = PaparazziScreenshotConfigAdapter(paparazziConfig) 26 | val sharedTestAdapter = PaparazziWrapperConfigAdapter(paparazziConfig) 27 | 28 | return Paparazzi( 29 | deviceConfig = configAdapter.getDeviceConfigFor(screenshotConfigForView).copy( 30 | softButtons = paparazziConfig.deviceSystemUiVisibility.softButtons, 31 | ), 32 | showSystemUi = paparazziConfig.deviceSystemUiVisibility.systemUi, 33 | supportsRtl = true, 34 | theme = screenshotConfigForView.theme ?: "android:Theme.Material.NoActionBar.Fullscreen", 35 | renderingMode = sharedTestAdapter.asRenderingMode(), 36 | maxPercentDifference = paparazziConfig.maxPercentageDiff, 37 | environment = sharedTestAdapter.asEnvironment(), 38 | renderExtensions = sharedTestAdapter.asRenderExtensions(), 39 | ) 40 | } 41 | } -------------------------------------------------------------------------------- /shot/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.shot' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_1_8 26 | targetCompatibility JavaVersion.VERSION_1_8 27 | } 28 | 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | 33 | buildFeatures { 34 | // Enables Jetpack Compose for this module 35 | compose true 36 | } 37 | 38 | composeOptions { 39 | kotlinCompilerExtensionVersion '1.5.15' 40 | } 41 | 42 | publishing { 43 | singleVariant("release") { 44 | withSourcesJar() 45 | withJavadocJar() 46 | } 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation project(':utils') 52 | api 'com.karumi:shot-android:6.1.0' 53 | api ("androidx.activity:activity-compose:1.9.3") { 54 | because "the last one requires compose option 1.6.x" 55 | } 56 | implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:6.1' 57 | } 58 | 59 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 60 | publishing { 61 | publications { 62 | release(MavenPublication) { 63 | groupId = 'com.github.sergio-sastre' 64 | artifactId = "shot" 65 | version = '2.8.0' 66 | 67 | afterEvaluate { 68 | from components.release 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/view/TestDataForViewCombinator.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.view 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ViewConfigItem 5 | 6 | class TestDataForViewCombinator>(uiStates: Array) { 7 | 8 | private var testConfigItems: Array> = uiStates.map { item -> 9 | TestDataForView(uiState = item) 10 | }.toTypedArray() 11 | 12 | fun forDevices(vararg deviceScreens: DeviceScreen): TestDataForViewCombinator = apply { 13 | val cartesianProductList = mutableListOf>() 14 | for (testItem in testConfigItems) { 15 | for (deviceScreen in deviceScreens) { 16 | cartesianProductList.add( 17 | TestDataForView( 18 | uiState = testItem.uiState, 19 | device = deviceScreen, 20 | config = testItem.config 21 | ) 22 | ) 23 | } 24 | } 25 | testConfigItems = cartesianProductList.toTypedArray() 26 | } 27 | 28 | fun forConfigs(vararg configs: ViewConfigItem): TestDataForViewCombinator = apply { 29 | val cartesianProductList = mutableListOf>() 30 | for (testItem in testConfigItems) { 31 | for (config in configs) { 32 | cartesianProductList.add( 33 | TestDataForView( 34 | uiState = testItem.uiState, 35 | device = testItem.device, 36 | config = config 37 | ) 38 | ) 39 | } 40 | } 41 | testConfigItems = cartesianProductList.toTypedArray() 42 | } 43 | 44 | fun combineAll() : Array> = testConfigItems.copyOf() 45 | } 46 | -------------------------------------------------------------------------------- /app/src/test/java/sergio/sastre/uitesting/myapplication/LocaleUtilTests.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.myapplication 2 | 3 | import org.junit.Test 4 | import sergio.sastre.uitesting.utils.common.LocaleUtil 5 | 6 | class LocaleUtilTests { 7 | 8 | @Test 9 | fun testLocaleWithAllVariants(){ 10 | val localeString = "zh-Latn-TW-pinyin" 11 | val locale = LocaleUtil.localeFromString(localeString) 12 | assert(locale.language == "zh") 13 | assert(locale.script == "Latn") 14 | assert(locale.country == "TW") 15 | assert(locale.variant == "pinyin") 16 | } 17 | 18 | @Test 19 | fun testSerbianLatin(){ 20 | val localeString = "sr-Latn-RS" 21 | val locale = LocaleUtil.localeFromString(localeString) 22 | assert(locale.language == "sr") 23 | assert(locale.script == "Latn") 24 | assert(locale.country == "RS") 25 | } 26 | 27 | @Test 28 | fun testSerbianCyrillic(){ 29 | val localeString = "sr-Cyrl-RS" 30 | val locale = LocaleUtil.localeFromString(localeString) 31 | assert(locale.language == "sr") 32 | assert(locale.script == "Cyrl") 33 | assert(locale.country == "RS") 34 | } 35 | 36 | @Test 37 | fun testSerbianRegion(){ 38 | val localeString = "sr-RS" 39 | val locale = LocaleUtil.localeFromString(localeString) 40 | assert(locale.language == "sr") 41 | assert(locale.country == "RS") 42 | } 43 | 44 | @Test 45 | fun testISOLocale(){ 46 | val localeString = "en_US" 47 | val locale = LocaleUtil.localeFromString(localeString) 48 | assert(locale.language == "en") 49 | assert(locale.country == "US") 50 | } 51 | 52 | @Test 53 | fun testPseudolocale(){ 54 | val localeString = "en_XA" 55 | val locale = LocaleUtil.localeFromString(localeString) 56 | assert(locale.language == "en") 57 | assert(locale.country == "XA") 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ViewConfigItem.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import androidx.annotation.StyleRes 4 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 5 | import sergio.sastre.uitesting.utils.common.DisplaySize 6 | import sergio.sastre.uitesting.utils.common.FontSize 7 | import sergio.sastre.uitesting.utils.common.FontSizeScale 8 | import sergio.sastre.uitesting.utils.common.FontWeight 9 | import sergio.sastre.uitesting.utils.common.Orientation 10 | import sergio.sastre.uitesting.utils.common.UiMode 11 | 12 | data class ViewConfigItem( 13 | val orientation: Orientation? = null, 14 | val uiMode: UiMode? = null, 15 | val locale: String? = null, 16 | val fontSize: FontSizeScale? = null, 17 | val displaySize: DisplaySize? = null, 18 | @get:StyleRes val theme: Int? = null, 19 | val fontWeight: FontWeight? = null, 20 | ) { 21 | val id : String 22 | get() { 23 | val nonNullProperties = mutableListOf() 24 | locale?.let { nonNullProperties.add(it.uppercase()) } 25 | uiMode?.let { nonNullProperties.add(it.name) } 26 | theme?.let { 27 | val themeInfo = getInstrumentation().targetContext.resources.getResourceName(it) 28 | // The themeInfo will be in the format: "android:style/Theme.XXX" 29 | val theme = themeInfo.substring(themeInfo.lastIndexOf('/') + 1) 30 | nonNullProperties.add(theme.replace(".","_").uppercase()) 31 | } 32 | fontSize?.let { nonNullProperties.add("FONT_${it.valueAsName()}") } 33 | fontWeight?.let { nonNullProperties.add("WEIGHT_${it.name}") } 34 | displaySize?.let { nonNullProperties.add("DISPLAY_${it.name}") } 35 | orientation?.let { nonNullProperties.add(it.name) } 36 | return nonNullProperties.joinToString(separator = "_") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/config/RobolectricQualifiersBuilder.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.config 2 | 3 | import org.robolectric.RuntimeEnvironment.setQualifiers 4 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 5 | import sergio.sastre.uitesting.utils.common.Orientation 6 | 7 | object RobolectricQualifiersBuilder { 8 | 9 | fun setQualifiers( 10 | deviceScreen: DeviceScreen?, 11 | configOrientation: Orientation?, 12 | ) { 13 | if (deviceScreen != null) { 14 | deviceScreen.apply { 15 | val listOfQualifiers = mutableListOf() 16 | widthDp.let { listOfQualifiers.add("w${it}dp") } 17 | heightDp.let { listOfQualifiers.add("h${it}dp") } 18 | size.let { listOfQualifiers.add(it.qualifier) } 19 | aspect.let { listOfQualifiers.add(it.qualifier) } 20 | round?.let { listOfQualifiers.add(it.qualifier) } 21 | orientationQualifier(configOrientation).let { listOfQualifiers.add(it) } 22 | type?.let { listOfQualifiers.add(it.qualifier) } 23 | density.let { listOfQualifiers.add(it.valueAsQualifier()) } 24 | 25 | setQualifiers(listOfQualifiers.joinToString(separator = "-")) 26 | } 27 | } else { 28 | configOrientation?.let { 29 | val orientation = if (it == Orientation.PORTRAIT) "port" else "land" 30 | setQualifiers("+$orientation") 31 | } 32 | } 33 | } 34 | 35 | /** 36 | * Returns the corresponding orientation, where config orientation always wins over 37 | * the device default orientation. 38 | */ 39 | private fun DeviceScreen.orientationQualifier(orientation: Orientation?): String = 40 | orientation?.let { 41 | if (it == Orientation.PORTRAIT) "port" else "land" 42 | } ?: this.defaultOrientation.qualifier 43 | } 44 | -------------------------------------------------------------------------------- /android-testify/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.android_testify' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles "consumer-rules.pro" 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_1_8 27 | targetCompatibility JavaVersion.VERSION_1_8 28 | } 29 | 30 | kotlinOptions { 31 | jvmTarget = '1.8' 32 | } 33 | 34 | buildFeatures { 35 | // Enables Jetpack Compose for this module 36 | compose true 37 | } 38 | 39 | composeOptions { 40 | kotlinCompilerExtensionVersion '1.5.15' 41 | } 42 | 43 | publishing { 44 | singleVariant("release") { 45 | withSourcesJar() 46 | withJavadocJar() 47 | } 48 | } 49 | } 50 | 51 | dependencies { 52 | implementation project(':utils') 53 | implementation 'androidx.test:rules:1.7.0' 54 | api 'dev.testify:testify:3.2.3' 55 | api ("androidx.activity:activity-compose:1.9.3") { 56 | because "the last one requires compose option 1.6.x" 57 | } 58 | } 59 | 60 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 61 | publishing { 62 | publications { 63 | release(MavenPublication) { 64 | groupId = 'com.github.sergio-sastre' 65 | artifactId = "android-testify" 66 | version = '2.8.0' 67 | 68 | afterEvaluate { 69 | from components.release 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/activity/TestDataForActivityCombinator.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.activity 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ActivityConfigItem 5 | 6 | class TestDataForActivityCombinator>(uiStates: Array) { 7 | 8 | private var testConfigItems: Array> = uiStates.map { item -> 9 | TestDataForActivity(uiState = item) 10 | }.toTypedArray() 11 | 12 | fun forDevices(vararg deviceScreens: DeviceScreen): TestDataForActivityCombinator = apply { 13 | val cartesianProductList = mutableListOf>() 14 | for (testItem in testConfigItems) { 15 | for (deviceScreen in deviceScreens) { 16 | cartesianProductList.add( 17 | TestDataForActivity( 18 | uiState = testItem.uiState, 19 | device = deviceScreen, 20 | config = testItem.config 21 | ) 22 | ) 23 | } 24 | } 25 | testConfigItems = cartesianProductList.toTypedArray() 26 | } 27 | 28 | fun forConfigs(vararg configs: ActivityConfigItem): TestDataForActivityCombinator = apply { 29 | val cartesianProductList = mutableListOf>() 30 | for (testItem in testConfigItems) { 31 | for (config in configs) { 32 | cartesianProductList.add( 33 | TestDataForActivity( 34 | uiState = testItem.uiState, 35 | device = testItem.device, 36 | config = config 37 | ) 38 | ) 39 | } 40 | } 41 | testConfigItems = cartesianProductList.toTypedArray() 42 | } 43 | 44 | fun combineAll() : Array> = testConfigItems.copyOf() 45 | } 46 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/fragment/TestDataForFragmentCombinator.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.fragment 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.fragmentscenario.FragmentConfigItem 5 | 6 | class TestDataForFragmentCombinator>(uiStates: Array) { 7 | 8 | private var testConfigItems: Array> = uiStates.map { item -> 9 | TestDataForFragment(uiState = item) 10 | }.toTypedArray() 11 | 12 | fun forDevices(vararg deviceScreens: DeviceScreen): TestDataForFragmentCombinator = apply { 13 | val cartesianProductList = mutableListOf>() 14 | for (testItem in testConfigItems) { 15 | for (deviceScreen in deviceScreens) { 16 | cartesianProductList.add( 17 | TestDataForFragment( 18 | uiState = testItem.uiState, 19 | device = deviceScreen, 20 | config = testItem.config 21 | ) 22 | ) 23 | } 24 | } 25 | testConfigItems = cartesianProductList.toTypedArray() 26 | } 27 | 28 | fun forConfigs(vararg configs: FragmentConfigItem): TestDataForFragmentCombinator = apply { 29 | val cartesianProductList = mutableListOf>() 30 | for (testItem in testConfigItems) { 31 | for (config in configs) { 32 | cartesianProductList.add( 33 | TestDataForFragment( 34 | uiState = testItem.uiState, 35 | device = testItem.device, 36 | config = config 37 | ) 38 | ) 39 | } 40 | } 41 | testConfigItems = cartesianProductList.toTypedArray() 42 | } 43 | 44 | fun combineAll() : Array> = testConfigItems.copyOf() 45 | } 46 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/providers/ScreenshotLibraryTestRuleForViewProvider.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.providers 2 | 3 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 4 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForView 5 | 6 | interface ScreenshotLibraryTestRuleForViewProvider { 7 | 8 | companion object ScreenshotTestRuleClassPath { 9 | const val PAPARAZZI = "sergio.sastre.uitesting.paparazzi.PaparazziScreenshotTestRuleForView" 10 | const val SHOT = "sergio.sastre.uitesting.shot.ShotScreenshotTestRuleForView" 11 | const val ANDROID_TESTIFY = "sergio.sastre.uitesting.android_testify.screenshotscenario.ScreenshotScenarioRuleForView" 12 | const val DROPSHOTS = "sergio.sastre.uitesting.dropshots.DropshotsScreenshotTestRuleForView" 13 | const val ROBORAZZI = "sergio.sastre.uitesting.roborazzi.RoborazziScreenshotTestRuleForView" 14 | } 15 | 16 | val config: ScreenshotConfigForView 17 | 18 | val dropshotsScreenshotTestRule 19 | get() = getScreenshotTestRuleClassForName(DROPSHOTS, config) 20 | 21 | val paparazziScreenshotTestRule 22 | get() = getScreenshotTestRuleClassForName(PAPARAZZI, config) 23 | 24 | val roborazziScreenshotTestRule 25 | get() = getScreenshotTestRuleClassForName(ROBORAZZI, config) 26 | 27 | val shotScreenshotTestRule 28 | get() = getScreenshotTestRuleClassForName(SHOT, config) 29 | 30 | val androidTestifyScreenshotTestRule 31 | get() = getScreenshotTestRuleClassForName(ANDROID_TESTIFY, config) 32 | 33 | fun getScreenshotTestRuleClassForName( 34 | className: String, 35 | config: ScreenshotConfigForView, 36 | ): ScreenshotTestRuleForView { 37 | return Class.forName(className) 38 | .getConstructor(ScreenshotConfigForView::class.java) 39 | .newInstance(config) as ScreenshotTestRuleForView 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/utils/composable/TestDataForComposableCombinator.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.utils.composable 2 | 3 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 4 | import sergio.sastre.uitesting.utils.activityscenario.ComposableConfigItem 5 | 6 | class TestDataForComposableCombinator>(uiStates: Array) { 7 | 8 | private var testDataItems: Array> = uiStates.map { item -> 9 | TestDataForComposable(uiState = item) 10 | }.toTypedArray() 11 | 12 | fun forDevices(vararg deviceScreens: DeviceScreen): TestDataForComposableCombinator = apply { 13 | val cartesianProductList = mutableListOf>() 14 | for (testItem in testDataItems) { 15 | for (deviceScreen in deviceScreens) { 16 | cartesianProductList.add( 17 | TestDataForComposable( 18 | uiState = testItem.uiState, 19 | device = deviceScreen, 20 | config = testItem.config 21 | ) 22 | ) 23 | } 24 | } 25 | testDataItems = cartesianProductList.toTypedArray() 26 | } 27 | 28 | fun forConfigs(vararg configs: ComposableConfigItem): TestDataForComposableCombinator = apply { 29 | val cartesianProductList = mutableListOf>() 30 | for (testItem in testDataItems) { 31 | for (config in configs) { 32 | cartesianProductList.add( 33 | TestDataForComposable( 34 | uiState = testItem.uiState, 35 | device = testItem.device, 36 | config = config 37 | ) 38 | ) 39 | } 40 | } 41 | testDataItems = cartesianProductList.toTypedArray() 42 | } 43 | 44 | fun combineAll() : Array> = testDataItems.copyOf() 45 | } 46 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/fontsize/FontScaleSetting.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.fontsize 2 | 3 | /** 4 | * Concept taken from : https://github.com/novoda/espresso-support 5 | */ 6 | import android.content.res.Resources 7 | import android.os.Build 8 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 9 | import sergio.sastre.uitesting.utils.common.FontSizeScale 10 | import sergio.sastre.uitesting.utils.utils.waitForExecuteShellCommand 11 | 12 | internal class FontScaleSetting internal constructor() { 13 | 14 | private val resources 15 | get() = getInstrumentation().targetContext.resources 16 | 17 | internal fun get(): FontSizeScale { 18 | return FontSizeScale.Value(resources.configuration.fontScale) 19 | } 20 | 21 | internal fun set(scale: FontSizeScale) { 22 | try { 23 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { 24 | changeFontScaleFromApi24(scale) 25 | } else { 26 | changeFontScalePreApi24(scale) 27 | } 28 | } catch (e: Exception) { 29 | throw saveFontScaleError(scale) 30 | } 31 | } 32 | 33 | private fun changeFontScaleFromApi24(fontScale: FontSizeScale) { 34 | getInstrumentation().waitForExecuteShellCommand( 35 | "settings put system font_scale " + fontScale.scale 36 | ) 37 | } 38 | 39 | @Suppress("DEPRECATION") 40 | private fun changeFontScalePreApi24(fontScale: FontSizeScale) { 41 | resources.configuration.fontScale = fontScale.scale 42 | val metrics = Resources.getSystem().displayMetrics 43 | metrics.scaledDensity = resources.configuration.fontScale * metrics.density 44 | resources.updateConfiguration(resources.configuration, metrics) 45 | } 46 | 47 | private fun saveFontScaleError(fontScale: FontSizeScale): RuntimeException { 48 | return RuntimeException("Unable to save font size scale" + fontScale.scale) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/config/PaparazziForComposableTestRuleBuilder.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi.config 2 | 3 | import app.cash.paparazzi.Paparazzi 4 | import sergio.sastre.uitesting.mapper.paparazzi.PaparazziConfig 5 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 6 | 7 | class PaparazziForComposableTestRuleBuilder { 8 | 9 | private var paparazziConfig: PaparazziConfig = PaparazziConfig() 10 | private var screenshotConfigForComposable: ScreenshotConfigForComposable = 11 | ScreenshotConfigForComposable() 12 | 13 | fun applyPaparazziConfig( 14 | paparazziConfig: PaparazziConfig, 15 | ): PaparazziForComposableTestRuleBuilder = apply { 16 | this.paparazziConfig = paparazziConfig 17 | } 18 | 19 | fun applyScreenshotConfig( 20 | screenshotConfigForComposable: ScreenshotConfigForComposable, 21 | ): PaparazziForComposableTestRuleBuilder = apply { 22 | this.screenshotConfigForComposable = screenshotConfigForComposable 23 | } 24 | 25 | fun build(): Paparazzi { 26 | val configAdapter = PaparazziScreenshotConfigAdapter(paparazziConfig) 27 | val sharedTestAdapter = PaparazziWrapperConfigAdapter(paparazziConfig) 28 | 29 | return Paparazzi( 30 | deviceConfig = configAdapter.getDeviceConfigFor(screenshotConfigForComposable).copy( 31 | softButtons = paparazziConfig.deviceSystemUiVisibility.softButtons, 32 | ), 33 | showSystemUi = paparazziConfig.deviceSystemUiVisibility.systemUi, 34 | supportsRtl = true, 35 | renderingMode = sharedTestAdapter.asRenderingMode(), 36 | maxPercentDifference = paparazziConfig.maxPercentageDiff, 37 | environment = sharedTestAdapter.asEnvironment(), 38 | renderExtensions = sharedTestAdapter.asRenderExtensions(), 39 | useDeviceResolution = paparazziConfig.useDeviceResolution, 40 | validateAccessibility = paparazziConfig.validateAccessibility, 41 | ) 42 | } 43 | } -------------------------------------------------------------------------------- /roborazzi/src/main/java/sergio/sastre/uitesting/roborazzi/RobolectricActivityScenarioForComposableTestRuleExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.roborazzi 2 | 3 | import androidx.activity.compose.setContent 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.ui.test.onRoot 6 | import com.github.takahirom.roborazzi.ExperimentalRoborazziApi 7 | import com.github.takahirom.roborazzi.RoborazziOptions 8 | import com.github.takahirom.roborazzi.captureRoboImage 9 | import com.github.takahirom.roborazzi.provideRoborazziContext 10 | import sergio.sastre.uitesting.robolectric.activityscenario.RobolectricActivityScenarioForComposableRule 11 | 12 | fun RobolectricActivityScenarioForComposableRule.setContent( 13 | composable: @Composable () -> Unit, 14 | ): RobolectricActivityScenarioForComposableRule { 15 | this.activityScenario 16 | .onActivity { 17 | it.setContent { composable.invoke() } 18 | } 19 | 20 | return this 21 | } 22 | 23 | fun RobolectricActivityScenarioForComposableRule.captureRoboImage( 24 | composable: @Composable () -> Unit, 25 | ){ 26 | activityScenario 27 | .onActivity { 28 | it.setContent { composable.invoke() } 29 | } 30 | 31 | composeRule.onRoot().captureRoboImage() 32 | } 33 | 34 | fun RobolectricActivityScenarioForComposableRule.captureRoboImage( 35 | filePath: String, 36 | composable: @Composable () -> Unit, 37 | ){ 38 | activityScenario 39 | .onActivity { 40 | it.setContent { composable.invoke() } 41 | } 42 | 43 | composeRule.onRoot().captureRoboImage(filePath = filePath) 44 | } 45 | 46 | fun RobolectricActivityScenarioForComposableRule.captureRoboImage( 47 | filePath: String, 48 | roborazziOptions: RoborazziOptions, 49 | composable: @Composable () -> Unit, 50 | ){ 51 | activityScenario 52 | .onActivity { 53 | it.setContent { composable.invoke() } 54 | } 55 | 56 | composeRule.onRoot().captureRoboImage( 57 | filePath = filePath, 58 | roborazziOptions = roborazziOptions, 59 | ) 60 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/providers/ScreenshotLibraryTestRuleForComposableProvider.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.providers 2 | 3 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 4 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 5 | 6 | interface ScreenshotLibraryTestRuleForComposableProvider { 7 | 8 | companion object ScreenshotTestRuleClassPath { 9 | const val PAPARAZZI = "sergio.sastre.uitesting.paparazzi.PaparazziScreenshotTestRuleForComposable" 10 | const val SHOT = "sergio.sastre.uitesting.shot.ShotScreenshotTestRuleForComposable" 11 | const val DROPSHOTS = "sergio.sastre.uitesting.dropshots.DropshotsScreenshotTestRuleForComposable" 12 | const val ANDROID_TESTIFY = "sergio.sastre.uitesting.android_testify.screenshotscenario.ScreenshotScenarioRuleForComposable" 13 | const val ROBORAZZI = "sergio.sastre.uitesting.roborazzi.RoborazziScreenshotTestRuleForComposable" 14 | } 15 | 16 | val config: ScreenshotConfigForComposable 17 | 18 | val dropshotsScreenshotTestRule 19 | get() = getScreenshotTestRuleClassForName(DROPSHOTS, config) 20 | 21 | val paparazziScreenshotTestRule 22 | get() = getScreenshotTestRuleClassForName(PAPARAZZI, config) 23 | 24 | val roborazziScreenshotTestRule 25 | get() = getScreenshotTestRuleClassForName(ROBORAZZI, config) 26 | 27 | val shotScreenshotTestRule 28 | get() = getScreenshotTestRuleClassForName(SHOT, config) 29 | 30 | val androidTestifyScreenshotTestRule 31 | get() = getScreenshotTestRuleClassForName(ANDROID_TESTIFY, config) 32 | 33 | fun getScreenshotTestRuleClassForName( 34 | className: String, 35 | config: ScreenshotConfigForComposable, 36 | ): ScreenshotTestRuleForComposable { 37 | return Class.forName(className) 38 | .getConstructor(ScreenshotConfigForComposable::class.java) 39 | .newInstance(config) as ScreenshotTestRuleForComposable 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /roborazzi/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | 7 | android { 8 | namespace 'sergio.sastre.uitesting.roborazzi' 9 | compileSdk 35 10 | 11 | defaultConfig { 12 | minSdk 23 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | } 16 | 17 | buildTypes { 18 | release { 19 | minifyEnabled false 20 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 21 | } 22 | } 23 | compileOptions { 24 | sourceCompatibility JavaVersion.VERSION_1_8 25 | targetCompatibility JavaVersion.VERSION_1_8 26 | } 27 | kotlinOptions { 28 | jvmTarget = '1.8' 29 | } 30 | 31 | buildFeatures { 32 | // Enables Jetpack Compose for this module 33 | compose true 34 | } 35 | 36 | composeOptions { 37 | kotlinCompilerExtensionVersion '1.5.15' 38 | } 39 | 40 | publishing { 41 | singleVariant("release") { 42 | withSourcesJar() 43 | withJavadocJar() 44 | } 45 | } 46 | } 47 | 48 | dependencies { 49 | implementation project(':utils') 50 | implementation project(':robolectric') 51 | implementation project(':mapper-roborazzi') 52 | 53 | implementation("org.hamcrest:hamcrest:3.0") 54 | implementation("io.github.darkxanter:webp-imageio:0.3.3") 55 | implementation("androidx.activity:activity-compose:1.9.3") { 56 | because "the last one requires compose option 1.6.x" 57 | } 58 | 59 | api 'io.github.takahirom.roborazzi:roborazzi:1.51.0' 60 | api 'androidx.test.espresso:espresso-core:3.7.0' 61 | } 62 | 63 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 64 | publishing { 65 | publications { 66 | release(MavenPublication) { 67 | groupId = 'com.github.sergio-sastre' 68 | artifactId = "roborazzi" 69 | version = '2.8.0' 70 | 71 | afterEvaluate { 72 | from components.release 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /android-testify/src/main/java/sergio/sastre/uitesting/android_testify/ScreenshotRuleWithConfigurationForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.android_testify 2 | 3 | import androidx.annotation.ColorInt 4 | import androidx.fragment.app.FragmentActivity 5 | import dev.testify.ScreenshotRule 6 | import dev.testify.core.TestifyConfiguration 7 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioConfigurator 8 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioConfigurator.LandscapeSnapshotConfiguredActivity 9 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioConfigurator.PortraitSnapshotConfiguredActivity 10 | import sergio.sastre.uitesting.utils.activityscenario.ViewConfigItem 11 | import sergio.sastre.uitesting.utils.common.Orientation 12 | 13 | class ScreenshotRuleWithConfigurationForView( 14 | exactness: Float? = null, 15 | enableReporter: Boolean = false, 16 | initialTouchMode: Boolean = false, 17 | config: ViewConfigItem? = null, 18 | @ColorInt activityBackgroundColor: Int? = null 19 | ) : ScreenshotRule( 20 | activityClass = getActivityClassFor(config?.orientation), 21 | enableReporter = enableReporter, 22 | initialTouchMode = initialTouchMode, 23 | configuration = TestifyConfiguration(exactness = exactness) 24 | ) { 25 | 26 | init { 27 | ActivityScenarioConfigurator.ForView() 28 | .apply { 29 | config?.uiMode?.run { setUiMode(this) } 30 | config?.locale?.run { setLocale(this) } 31 | config?.fontSize?.run { setFontSize(this) } 32 | config?.displaySize?.run { setDisplaySize(this) } 33 | config?.theme?.run { setTheme(this) } 34 | activityBackgroundColor?.run { setActivityBackgroundColor(this) } 35 | } 36 | } 37 | } 38 | 39 | @Suppress("UNCHECKED_CAST") 40 | private fun getActivityClassFor(orientation: Orientation?): Class = 41 | when (orientation == Orientation.LANDSCAPE) { 42 | true -> LandscapeSnapshotConfiguredActivity::class.java 43 | false -> PortraitSnapshotConfiguredActivity::class.java 44 | } as Class 45 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/fragmentscenario/RobolectricFragmentScenarioConfiguratorRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.fragmentscenario 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | import androidx.fragment.app.FragmentFactory 6 | import androidx.lifecycle.Lifecycle 7 | import org.junit.rules.ExternalResource 8 | import sergio.sastre.uitesting.robolectric.config.RobolectricQualifiersBuilder 9 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 10 | import sergio.sastre.uitesting.utils.fragmentscenario.FragmentConfigItem 11 | 12 | class RobolectricFragmentScenarioConfiguratorRule( 13 | fragmentClass: Class, 14 | fragmentArgs: Bundle?, 15 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 16 | factory: FragmentFactory? = null, 17 | val deviceScreen: DeviceScreen? = null, 18 | val config: FragmentConfigItem? = null, 19 | ) : ExternalResource() { 20 | 21 | val fragmentScenario = 22 | RobolectricFragmentScenarioConfigurator.ForFragment() 23 | .apply { 24 | RobolectricQualifiersBuilder.setQualifiers( 25 | deviceScreen = deviceScreen, 26 | configOrientation = config?.orientation, 27 | ) 28 | } 29 | .apply { 30 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 31 | config?.locale?.also { locale -> setLocale(locale) } 32 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 33 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 34 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 35 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 36 | config?.theme?.also { theme -> setTheme(theme) } 37 | }.launchInContainer(fragmentClass, fragmentArgs, initialState, factory) 38 | 39 | val fragment: Fragment by lazy { 40 | fragmentScenario.waitForFragment() 41 | } 42 | 43 | override fun after() { 44 | fragmentScenario.close() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/PaparazziScreenshotTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi 2 | 3 | import app.cash.paparazzi.Paparazzi 4 | import org.junit.runner.Description 5 | import org.junit.runners.model.Statement 6 | import androidx.compose.runtime.Composable 7 | import sergio.sastre.uitesting.paparazzi.config.PaparazziForComposableTestRuleBuilder 8 | import sergio.sastre.uitesting.mapper.paparazzi.PaparazziConfig 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 11 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 12 | 13 | class PaparazziScreenshotTestRuleForComposable( 14 | override val config: ScreenshotConfigForComposable = ScreenshotConfigForComposable(), 15 | ) : ScreenshotTestRuleForComposable(config) { 16 | 17 | private var paparazziConfig = PaparazziConfig() 18 | 19 | private val paparazziTestRule: Paparazzi by lazy { 20 | PaparazziForComposableTestRuleBuilder() 21 | .applyPaparazziConfig(paparazziConfig) 22 | .applyScreenshotConfig(config) 23 | .build() 24 | } 25 | 26 | override fun apply(base: Statement, description: Description): Statement = 27 | paparazziTestRule.apply(base, description) 28 | 29 | override fun snapshot(name: String?, composable: @Composable () -> Unit) { 30 | paparazziTestRule.context.run { 31 | setFontWeight(config.fontWeight) 32 | setDisplaySize(config.displaySize) 33 | } 34 | paparazziTestRule.snapshot(name) { composable() } 35 | } 36 | 37 | override fun snapshot(composable: @Composable () -> Unit) { 38 | paparazziTestRule.context.run { 39 | setFontWeight(config.fontWeight) 40 | setDisplaySize(config.displaySize) 41 | } 42 | paparazziTestRule.snapshot { composable() } 43 | } 44 | 45 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable = apply { 46 | if (config is PaparazziConfig) { 47 | paparazziConfig = config 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/activityscenario/RobolectricActivityScenarioForViewRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.activityscenario 2 | 3 | import android.app.Activity 4 | import androidx.annotation.ColorInt 5 | import androidx.annotation.LayoutRes 6 | import org.junit.rules.ExternalResource 7 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 8 | import sergio.sastre.uitesting.utils.activityscenario.ViewConfigItem 9 | import sergio.sastre.uitesting.utils.utils.inflateAndWaitForIdle 10 | import sergio.sastre.uitesting.utils.utils.waitForActivity 11 | 12 | /** 13 | * Returns an ActivityScenario whose activity is configured with the given parameters 14 | * 15 | * WARNING: Do not use together with @Config(qualifiers = "my_qualifiers") to avoid 16 | * any misbehaviour 17 | */ 18 | class RobolectricActivityScenarioForViewRule( 19 | deviceScreen: DeviceScreen? = null, 20 | config: ViewConfigItem? = null, 21 | @ColorInt backgroundColor: Int? = null, 22 | ) : ExternalResource() { 23 | 24 | val activityScenario = 25 | RobolectricActivityScenarioConfigurator.ForView() 26 | .apply { 27 | deviceScreen?.also { screen -> setDeviceScreen(screen) } 28 | }.apply { 29 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 30 | config?.locale?.also { locale -> setLocale(locale) } 31 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 32 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 33 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 34 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 35 | config?.theme?.also { theme -> setTheme(theme) } 36 | }.launchConfiguredActivity(backgroundColor) 37 | 38 | val activity: Activity by lazy { activityScenario.waitForActivity() } 39 | 40 | fun inflateAndWaitForIdle(@LayoutRes layoutId: Int, id: Int = android.R.id.content) = 41 | activity.inflateAndWaitForIdle(layoutId, id) 42 | 43 | override fun after() { 44 | activityScenario.close() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/locale/SystemLocaleTestRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.locale 2 | 3 | import org.junit.rules.TestRule 4 | import org.junit.runner.Description 5 | import org.junit.runners.model.Statement 6 | import sergio.sastre.uitesting.utils.common.LocaleListCompat 7 | import sergio.sastre.uitesting.utils.common.LocaleUtil 8 | import sergio.sastre.uitesting.utils.utils.grantChangeConfigurationIfNeeded 9 | import java.util.* 10 | import kotlin.Throws 11 | 12 | /** 13 | * A TestRule to change the Locale of the device via reflection and non-SDK APIs 14 | * (Configuration#updateConfiguration). It grants the CHANGE_CONFIGURATION permission via adb 15 | * 16 | * Code from fastlane Screengrab 17 | * @see https://github.com/fastlane/fastlane/blob/master/screengrab/screengrab-lib/src/main/java/tools.fastlane.screengrab/locale/LocaleTestRule.java 18 | * 19 | * The code was converted to Kotlin, and added the [grantChangeConfigurationIfNeeded] method 20 | * to enable locale change 21 | * 22 | * WARNING 1: It's not compatible with Robolectric 23 | * WARNING 2: If you are also using [InAppLocaleTestRule], make sure that [SystemLocaleTestRule] is applied before it (e.g. has a lower order number). 24 | * Otherwise, the System Locale is not reset correctly on API 36+ 25 | */ 26 | class SystemLocaleTestRule constructor(private val locale: Locale) : TestRule { 27 | 28 | constructor(testLocale: String) : this(LocaleUtil.localeFromString(testLocale)) 29 | 30 | override fun apply(base: Statement, description: Description): Statement { 31 | return object : Statement() { 32 | @Throws(Throwable::class) 33 | override fun evaluate() { 34 | var original: LocaleListCompat? = null 35 | try { 36 | grantChangeConfigurationIfNeeded() 37 | original = LocaleUtil.changeDeviceLocaleTo(LocaleListCompat(locale)) 38 | base.evaluate() 39 | } finally { 40 | if (original != null) { 41 | LocaleUtil.changeDeviceLocaleTo(original) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/implementations/shared/SharedScreenshotLibraryTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.implementations.shared 2 | 3 | import androidx.compose.runtime.Composable 4 | import org.junit.runner.Description 5 | import org.junit.runners.model.Statement 6 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 7 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 8 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 9 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.providers.ScreenshotLibraryTestRuleForComposableProvider 10 | import java.util.* 11 | 12 | abstract class SharedScreenshotLibraryTestRuleForComposable( 13 | override val config: ScreenshotConfigForComposable, 14 | ) : ScreenshotTestRuleForComposable(config), ScreenshotLibraryTestRuleForComposableProvider { 15 | 16 | private val factory: ScreenshotTestRuleForComposable by lazy { 17 | if (isRunningOnJvm()) { 18 | getJvmScreenshotLibraryTestRule(config) 19 | } else { 20 | getInstrumentedScreenshotLibraryTestRule(config) 21 | } 22 | } 23 | 24 | abstract fun getJvmScreenshotLibraryTestRule(config: ScreenshotConfigForComposable): ScreenshotTestRuleForComposable 25 | 26 | abstract fun getInstrumentedScreenshotLibraryTestRule(config: ScreenshotConfigForComposable): ScreenshotTestRuleForComposable 27 | 28 | private fun isRunningOnJvm(): Boolean = 29 | System.getProperty("java.runtime.name") 30 | ?.lowercase(Locale.getDefault()) 31 | ?.contains("android") != true 32 | 33 | override fun apply(base: Statement?, description: Description?): Statement { 34 | return factory.apply(base, description) 35 | } 36 | 37 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable { 38 | return factory.configure(config) 39 | } 40 | 41 | override fun snapshot(composable: @Composable () -> Unit) { 42 | factory.snapshot { composable() } 43 | } 44 | 45 | override fun snapshot(name: String?, composable: @Composable () -> Unit) { 46 | factory.snapshot(name) { composable() } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /utils/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | id 'maven-publish' 5 | id 'org.jetbrains.kotlin.android' 6 | } 7 | 8 | android { 9 | namespace = 'sergio.sastre.uitesting.utils' 10 | compileSdk 35 11 | 12 | defaultConfig { 13 | minSdk 23 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | consumerProguardFiles "consumer-rules.pro" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | 31 | kotlinOptions { 32 | jvmTarget = '1.8' 33 | freeCompilerArgs += ["-Xjvm-default=all"] 34 | } 35 | 36 | buildFeatures { 37 | // Enables Jetpack Compose for this module 38 | compose true 39 | } 40 | 41 | composeOptions { 42 | kotlinCompilerExtensionVersion '1.5.15' 43 | } 44 | 45 | publishing { 46 | singleVariant("release") { 47 | withSourcesJar() 48 | withJavadocJar() 49 | } 50 | } 51 | } 52 | 53 | dependencies { 54 | api 'androidx.appcompat:appcompat:1.7.1' 55 | api 'com.google.android.material:material:1.13.0' 56 | api 'androidx.test.espresso:espresso-core:3.7.0' 57 | api 'androidx.compose.ui:ui-test-junit4:1.9.4' 58 | api 'androidx.fragment:fragment-ktx:1.8.9' 59 | implementation 'androidx.test:core:1.7.0' 60 | implementation ('androidx.core:core-ktx:1.16.0') { 61 | because "it crashes with newer versions if executing ./gradlew :app:checkDebugAarMetadata" 62 | } 63 | implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:6.1' 64 | } 65 | 66 | //https://www.talentica.com/blogs/publish-your-android-library-on-jitpack-for-better-reachability/ 67 | publishing { 68 | publications { 69 | release(MavenPublication) { 70 | groupId = 'com.github.sergio-sastre' 71 | artifactId = "utils" 72 | version = '2.8.0' 73 | 74 | afterEvaluate { 75 | from components.release 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/ActivityScenarioForComposableRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario 2 | 3 | import android.app.Activity 4 | import androidx.annotation.ColorInt 5 | import androidx.compose.ui.platform.ComposeView 6 | import androidx.compose.ui.test.junit4.ComposeTestRule 7 | import androidx.compose.ui.test.junit4.createEmptyComposeRule 8 | import org.junit.rules.ExternalResource 9 | import org.junit.runner.Description 10 | import org.junit.runners.model.Statement 11 | import sergio.sastre.uitesting.utils.utils.waitForActivity 12 | import sergio.sastre.uitesting.utils.utils.waitForComposeView 13 | 14 | class ActivityScenarioForComposableRule( 15 | config: ComposableConfigItem? = null, 16 | @ColorInt backgroundColor: Int? = null, 17 | ) : ExternalResource() { 18 | 19 | private val emptyComposeRule = createEmptyComposeRule() 20 | 21 | val composeRule: ComposeTestRule by lazy { 22 | activityScenario.waitForActivity() 23 | emptyComposeRule.apply { waitForIdle() } 24 | } 25 | 26 | val activityScenario = 27 | ActivityScenarioConfigurator.ForComposable().apply { 28 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 29 | config?.locale?.also { locale -> setLocale(locale) } 30 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 31 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 32 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 33 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 34 | }.launchConfiguredActivity(backgroundColor) 35 | 36 | val activity: Activity by lazy { activityScenario.waitForActivity() } 37 | 38 | val composeView: ComposeView by lazy { 39 | val activity = activityScenario.waitForActivity() 40 | emptyComposeRule.waitForIdle() 41 | activity.waitForComposeView() 42 | } 43 | 44 | override fun apply(base: Statement?, description: Description?): Statement { 45 | // we need to call super, otherwise after() will not be called 46 | val externalResourceStatement = super.apply(base, description) 47 | return emptyComposeRule.apply(externalResourceStatement, description) 48 | } 49 | 50 | override fun after() { 51 | activityScenario.close() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/common/FontSize.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.common 2 | 3 | import android.os.Build.VERSION.SDK_INT 4 | import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE 5 | import androidx.annotation.Discouraged 6 | import org.junit.Assume 7 | 8 | interface FontSizeScale { 9 | 10 | companion object { 11 | fun supportedValuesForCurrentSdk(): Array = 12 | when (SDK_INT >= UPSIDE_DOWN_CAKE) { 13 | true -> arrayOf( 14 | FontSize.SMALL, 15 | FontSize.NORMAL, 16 | FontSize.LARGE, 17 | FontSize.XLARGE, 18 | FontSize.XXLARGE, 19 | FontSize.XXXLARGE, 20 | FontSize.LARGEST 21 | ) 22 | false -> arrayOf( 23 | FontSize.SMALL, 24 | FontSize.NORMAL, 25 | FontSize.LARGE, 26 | FontSize.LARGEST 27 | ) 28 | } 29 | } 30 | 31 | val scale: Float 32 | 33 | fun valueAsName(): String = 34 | when (this is Enum<*>) { 35 | true -> this.name 36 | false -> this.scale.toString().replace(".", "_") + "f" 37 | } 38 | 39 | class Value(override val scale: Float) : FontSizeScale 40 | } 41 | 42 | private const val MAX_LINEAR_FONT_SCALE = 1.3f 43 | private const val MAX_NON_LINEAR_FONT_SCALE = 2.0f 44 | 45 | enum class FontSize(override val scale: Float) : FontSizeScale { 46 | @Discouraged("Use LARGEST instead. Alternatively, also XLARGE or MAXIMUM_LINEAR") 47 | HUGE(MAX_LINEAR_FONT_SCALE), 48 | 49 | SMALL(0.85f), 50 | NORMAL(1f), 51 | LARGE(1.15f), 52 | XLARGE(MAX_LINEAR_FONT_SCALE), 53 | XXLARGE(1.5f), 54 | XXXLARGE(1.8f), 55 | LARGEST(maxFontSizeScaleSupported), 56 | 57 | MAXIMUM_LINEAR(MAX_LINEAR_FONT_SCALE), 58 | MAXIMUM_NON_LINEAR(MAX_NON_LINEAR_FONT_SCALE); 59 | 60 | val value: String = scale.toString() 61 | } 62 | 63 | 64 | private val maxFontSizeScaleSupported = 65 | when (SDK_INT >= UPSIDE_DOWN_CAKE) { 66 | true -> MAX_NON_LINEAR_FONT_SCALE 67 | false -> MAX_LINEAR_FONT_SCALE 68 | } 69 | 70 | fun assumeSdkSupports(fontSizeScale: FontSizeScale?) { 71 | fontSizeScale?.scale?.run { 72 | Assume.assumeTrue(this <= maxFontSizeScaleSupported) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/fragmentscenario/RobolectricFragmentScenarioConfiguratorExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.fragmentscenario 2 | 3 | import android.os.Bundle 4 | import androidx.fragment.app.Fragment 5 | import androidx.fragment.app.FragmentFactory 6 | import androidx.lifecycle.Lifecycle 7 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 8 | import sergio.sastre.uitesting.utils.fragmentscenario.FragmentConfigItem 9 | 10 | /** 11 | * Returns a [Fragment] from a [RobolectricFragmentScenarioConfigurator]. 12 | */ 13 | fun RobolectricFragmentScenarioConfigurator.waitForFragment(): A { 14 | var fragment: A? = null 15 | onFragment { 16 | fragment = it 17 | } 18 | return if (fragment != null) { 19 | fragment!! 20 | } else { 21 | throw IllegalStateException("The fragment scenario could not be initialized.") 22 | } 23 | } 24 | 25 | /** 26 | * Based on FragmentScenario from Google, optimized for Robolectric tests. 27 | * 28 | * WARNING: If any DeviceScreen is set, do not use together 29 | * with @Config(qualifiers = "my_qualifiers") to avoid any misbehaviour 30 | */ 31 | inline fun RobolectricFragmentScenarioConfigurator.launchInContainer( 32 | fragmentArgs: Bundle? = null, 33 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 34 | factory: FragmentFactory? = null 35 | ): RobolectricFragmentScenarioConfigurator = 36 | RobolectricFragmentScenarioConfigurator.ForFragment().launchInContainer( 37 | T::class.java, 38 | fragmentArgs, 39 | initialState, 40 | factory, 41 | ) 42 | 43 | /** 44 | * Based on FragmentScenario from Google, optimized for Robolectric tests. 45 | * 46 | * WARNING: If any DeviceScreen is set, do not use together 47 | * with @Config(qualifiers = "my_qualifiers") to avoid any misbehaviour 48 | */ 49 | inline fun robolectricFragmentScenarioConfiguratorRule( 50 | fragmentArgs: Bundle? = null, 51 | initialState: Lifecycle.State = Lifecycle.State.RESUMED, 52 | factory: FragmentFactory? = null, 53 | deviceScreen: DeviceScreen? = null, 54 | config: FragmentConfigItem? = null, 55 | ): RobolectricFragmentScenarioConfiguratorRule = RobolectricFragmentScenarioConfiguratorRule( 56 | T::class.java, 57 | fragmentArgs, 58 | initialState, 59 | factory, 60 | deviceScreen, 61 | config, 62 | ) 63 | -------------------------------------------------------------------------------- /mapper-paparazzi/src/main/java/sergio/sastre/uitesting/mapper/paparazzi/PaparazziConfig.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.mapper.paparazzi 2 | 3 | import sergio.sastre.uitesting.mapper.paparazzi.wrapper.AccessibilityRenderExtension 4 | import sergio.sastre.uitesting.mapper.paparazzi.wrapper.DeviceConfig 5 | import sergio.sastre.uitesting.mapper.paparazzi.wrapper.Environment 6 | import sergio.sastre.uitesting.mapper.paparazzi.wrapper.RenderExtension 7 | import sergio.sastre.uitesting.mapper.paparazzi.wrapper.RenderingMode 8 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 9 | 10 | /** 11 | * A wrapper for allowed Paparazzi configuration in shared Tests. 12 | * 13 | * This is necessary because we cannot access Paparazzi dependencies directly in androidTests 14 | * (including shared Tests running on a device or emulator), which would throw 15 | * a class Duplication error at Runtime. 16 | * 17 | * WARNING: 18 | * @param renderingMode: if null, the screenshot is automatically resized to that of the View, 19 | * considering the orientation (e.g. width and height switched for Landscape) 20 | * @param softButtons: false for all wrapped DeviceConfigs, contrary to Paparazzi's default. 21 | * That's because of this default renderingMode: if softButtons == true, they overlap 22 | * the view, and are displaced on top of it. 23 | */ 24 | data class PaparazziConfig( 25 | val maxPercentageDiff: Double = 0.1, 26 | val deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5, 27 | val deviceSystemUiVisibility: DeviceSystemUiVisibility = DeviceSystemUiVisibility(), 28 | val environment: Environment? = null, 29 | val snapshotViewOffsetMillis: Long = 0L, 30 | val renderingMode: RenderingMode? = null, 31 | val renderExtensions: Set = emptySet(), 32 | val useDeviceResolution: Boolean = false, 33 | val validateAccessibility: Boolean = false, 34 | ) : LibraryConfig { 35 | 36 | fun overrideForDefaultAccessibility(): PaparazziConfig { 37 | return this.copy( 38 | deviceConfig = this.deviceConfig.increaseWidthForAccessibilityExtension(), 39 | renderingMode = RenderingMode.NORMAL, 40 | renderExtensions = setOf(AccessibilityRenderExtension()), 41 | validateAccessibility = false, // Paparazzi does not support it together with RenderExtensions 42 | ) 43 | } 44 | } 45 | 46 | data class DeviceSystemUiVisibility( 47 | val softButtons: Boolean = false, 48 | val systemUi: Boolean = false, 49 | ) 50 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/uiMode/AdbUiModeSetter.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.uiMode 2 | 3 | import android.content.Context 4 | import android.content.res.Configuration 5 | import android.util.Log 6 | import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | import sergio.sastre.uitesting.utils.common.UiMode 10 | import sergio.sastre.uitesting.utils.utils.waitForExecuteShellCommand 11 | 12 | internal class AdbUiModeSetter( 13 | val base: Statement, 14 | val description: Description 15 | ): UiModeSetter { 16 | 17 | companion object { 18 | private const val UI_MODE_NIGHT_YES_COMMAND_VALUE = "yes" 19 | private const val UI_MODE_NIGHT_NO_COMMAND_VALUE = "no" 20 | private const val UI_MODE_NIGHT_AUTO_COMMAND_VALUE = "auto" 21 | 22 | private val TAG = AdbUiModeSetter::class.java.simpleName 23 | } 24 | override fun setUiModeDuringTestOnly(uiMode: UiMode) { 25 | val initialUiMode = getInstrumentation().targetContext.uiModeNight 26 | try { 27 | getInstrumentation().waitForExecuteShellCommand(uiModeCommand(uiMode)) 28 | base.evaluate() 29 | } catch (throwable: Throwable) { 30 | val testName = "${description.testClass.simpleName}\$${description.methodName}" 31 | val errorMessage = 32 | "Test $testName failed on setting uiMode to ${uiMode.name}" 33 | Log.e(TAG, errorMessage) 34 | throw throwable 35 | } finally { 36 | getInstrumentation().waitForExecuteShellCommand(uiModeCommand(initialUiMode)) 37 | } 38 | } 39 | 40 | private val Context.uiModeNight 41 | get() = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK 42 | 43 | private fun uiModeCommand(uiMode: Int): String { 44 | val value = when (uiMode) { 45 | Configuration.UI_MODE_NIGHT_YES -> UI_MODE_NIGHT_YES_COMMAND_VALUE 46 | Configuration.UI_MODE_NIGHT_NO -> UI_MODE_NIGHT_NO_COMMAND_VALUE 47 | else -> UI_MODE_NIGHT_AUTO_COMMAND_VALUE 48 | } 49 | return "cmd uimode night $value" 50 | } 51 | 52 | private fun uiModeCommand(uiMode: UiMode): String { 53 | val value = when (uiMode) { 54 | UiMode.NIGHT -> UI_MODE_NIGHT_YES_COMMAND_VALUE 55 | UiMode.DAY -> UI_MODE_NIGHT_NO_COMMAND_VALUE 56 | } 57 | return "cmd uimode night $value" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/implementations/ScreenshotLibraryTestRuleForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.implementations 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.view.View 6 | import androidx.recyclerview.widget.RecyclerView 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 11 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForView 12 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.providers.ScreenshotLibraryTestRuleForViewProvider 13 | 14 | abstract class ScreenshotLibraryTestRuleForView ( 15 | override val config: ScreenshotConfigForView, 16 | ) : ScreenshotTestRuleForView(config), ScreenshotLibraryTestRuleForViewProvider { 17 | 18 | private val factory: ScreenshotTestRuleForView by lazy { 19 | getScreenshotLibraryTestRule(config) 20 | } 21 | 22 | abstract fun getScreenshotLibraryTestRule(config: ScreenshotConfigForView): ScreenshotTestRuleForView 23 | 24 | override fun apply(base: Statement?, description: Description?): Statement { 25 | return factory.apply(base, description) 26 | } 27 | 28 | override val context: Context 29 | get() = factory.context 30 | 31 | override fun inflate(layoutId: Int): View = 32 | factory.inflate(layoutId) 33 | 34 | override fun waitForMeasuredView(actionToDo: () -> View): View = 35 | factory.waitForMeasuredView(actionToDo) 36 | 37 | override fun waitForMeasuredDialog(actionToDo: () -> Dialog): Dialog = 38 | factory.waitForMeasuredDialog(actionToDo) 39 | 40 | override fun waitForMeasuredViewHolder(actionToDo: () -> RecyclerView.ViewHolder): RecyclerView.ViewHolder = 41 | factory.waitForMeasuredViewHolder(actionToDo) 42 | 43 | override fun snapshotDialog(name: String?, dialog: Dialog) { 44 | factory.snapshotDialog(name, dialog) 45 | } 46 | 47 | override fun snapshotView(name: String?, view: View) { 48 | factory.snapshotView(name, view) 49 | } 50 | 51 | override fun snapshotViewHolder(name: String?, viewHolder: RecyclerView.ViewHolder) { 52 | factory.snapshotViewHolder(name, viewHolder) 53 | } 54 | 55 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForView { 56 | return factory.configure(config) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/config/ScreenshotConfigForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.config 2 | 3 | import android.annotation.SuppressLint 4 | import androidx.annotation.StyleRes 5 | import androidx.test.platform.app.InstrumentationRegistry 6 | import sergio.sastre.uitesting.utils.activityscenario.ViewConfigItem 7 | import sergio.sastre.uitesting.utils.common.DisplaySize 8 | import sergio.sastre.uitesting.utils.common.FontSize 9 | import sergio.sastre.uitesting.utils.common.FontSizeScale 10 | import sergio.sastre.uitesting.utils.common.FontWeight 11 | import sergio.sastre.uitesting.utils.common.Orientation 12 | import sergio.sastre.uitesting.utils.common.UiMode 13 | 14 | /** 15 | * Configuration that all screenshot libraries support for cross-library screenshot tests. 16 | * 17 | * Warning: The locale should be in IEFT BCP 47 format: 18 | * language-extlang-script-region-variant-extension-privateuse 19 | * 20 | * Warning: The Theme should be in in string form, as accepted by Paparazzi, for instance: 21 | * "Theme.Custom" or "android:Theme.Material.NoActionBar.Fullscreen" 22 | * 23 | * For instance: 24 | * sr-Cyrl-RS // for Serbian written in Cyrillic 25 | * zh-Latn-TW-pinyin // for Chinese language spoken in Taiwan in pinyin 26 | * ca-ES-valencia // for Catalan spoken in Valencia 27 | * 28 | */ 29 | data class ScreenshotConfigForView( 30 | val orientation: Orientation = Orientation.PORTRAIT, 31 | val uiMode: UiMode = UiMode.DAY, 32 | val locale: String = "en", 33 | val fontSize: FontSizeScale = FontSize.NORMAL, 34 | val displaySize: DisplaySize = DisplaySize.NORMAL, 35 | val theme: String? = null, 36 | val fontWeight: FontWeight = FontWeight.NORMAL, 37 | ) { 38 | fun toViewConfig(): ViewConfigItem = 39 | ViewConfigItem( 40 | orientation = orientation, 41 | uiMode = uiMode, 42 | locale = locale, 43 | fontSize = fontSize, 44 | displaySize = displaySize, 45 | theme = theme.asTheme(), 46 | fontWeight = fontWeight, 47 | ) 48 | 49 | // Do not use with Paparazzi, since its context is different from this 50 | private val context 51 | get() = InstrumentationRegistry.getInstrumentation().targetContext 52 | 53 | @SuppressLint("DiscouragedApi") 54 | @StyleRes 55 | private fun String?.asTheme(): Int? = 56 | this?.let { 57 | val styleRes = "${context.packageName}:style/${it.replace("_", ".")}" 58 | context.resources.getIdentifier( 59 | styleRes, 60 | "style", 61 | context.packageName 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/testrules/locale/ApiDependentInAppLocaleTestRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.testrules.locale 2 | 3 | import android.os.Handler 4 | import android.os.Looper 5 | import android.util.Log 6 | import androidx.appcompat.app.AppCompatDelegate.getApplicationLocales 7 | import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales 8 | import androidx.core.os.LocaleListCompat 9 | import org.junit.rules.TestRule 10 | import org.junit.runner.Description 11 | import org.junit.runners.model.Statement 12 | import sergio.sastre.uitesting.utils.common.LocaleUtil 13 | import java.util.Locale 14 | 15 | internal class ApiDependentInAppLocaleTestRule constructor( 16 | private val locale: Locale 17 | ) : TestRule { 18 | 19 | companion object Companion { 20 | private val TAG = InAppLocaleTestRule::class.java.simpleName 21 | } 22 | 23 | private var initialLocales: LocaleListCompat? = null 24 | 25 | constructor( 26 | testLocale: String 27 | ) : this(LocaleUtil.localeFromString(testLocale)) 28 | 29 | private val appLocalesLanguageTags 30 | get() = getApplicationLocales().toLanguageTags().ifBlank { "empty" } 31 | 32 | override fun apply(base: Statement, description: Description): Statement { 33 | return object : Statement() { 34 | @Throws(Throwable::class) 35 | override fun evaluate() { 36 | try { 37 | initialLocales = getApplicationLocales() 38 | val targetLocale = LocaleListCompat.create(locale) 39 | Log.d(TAG, "initial in-app locales is $appLocalesLanguageTags") 40 | setApplicationLocaleInLooper(Looper.getMainLooper(), targetLocale) 41 | base.evaluate() 42 | } catch (throwable: Throwable) { 43 | val testName = "${description.testClass.simpleName}\$${description.methodName}" 44 | val errorMessage = 45 | "Test $testName failed on setting inAppLocale to ${locale.toLanguageTag()}" 46 | Log.e(TAG, errorMessage) 47 | throw throwable 48 | } finally { 49 | initialLocales?.let { locales -> 50 | setApplicationLocaleInLooper(Looper.getMainLooper(), locales) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | private fun setApplicationLocaleInLooper(looper: Looper, locales: LocaleListCompat) { 58 | Handler(looper).post { 59 | setApplicationLocales(locales) 60 | Log.d(TAG, "in-app locales set to $appLocalesLanguageTags") 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /dropshots/src/main/java/sergio/sastre/uitesting/dropshots/DropshotsScreenshotTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.dropshots 2 | 3 | import androidx.compose.runtime.Composable 4 | import com.dropbox.dropshots.Dropshots 5 | import org.junit.rules.RuleChain 6 | import org.junit.runner.Description 7 | import org.junit.runners.model.Statement 8 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 10 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioForComposableRule 11 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 12 | 13 | class DropshotsScreenshotTestRuleForComposable( 14 | override val config: ScreenshotConfigForComposable = ScreenshotConfigForComposable(), 15 | ) : ScreenshotTestRuleForComposable(config) { 16 | 17 | private val activityScenarioForComposableRule by lazy { 18 | ActivityScenarioForComposableRule( 19 | config = config.toComposableConfig(), 20 | backgroundColor = dropshotsConfig.backgroundColor, 21 | ) 22 | } 23 | 24 | private val dropshotsRule: ScreenshotTaker by lazy { 25 | ScreenshotTaker( 26 | Dropshots( 27 | resultValidator = dropshotsConfig.resultValidator, 28 | imageComparator = dropshotsConfig.imageComparator, 29 | ) 30 | ) 31 | } 32 | 33 | private var dropshotsConfig: DropshotsConfig = DropshotsConfig() 34 | 35 | override fun snapshot(composable: @Composable () -> Unit) { 36 | takeSnapshot(null, composable) 37 | } 38 | 39 | override fun snapshot(name: String?, composable: @Composable () -> Unit) { 40 | takeSnapshot(name, composable) 41 | } 42 | 43 | override fun apply( 44 | base: Statement, 45 | description: Description, 46 | ): Statement = 47 | RuleChain 48 | .outerRule(dropshotsRule) 49 | .around(activityScenarioForComposableRule) 50 | .apply(base, description) 51 | 52 | private fun takeSnapshot(name: String?, composable: @Composable () -> Unit) { 53 | val composeView = 54 | activityScenarioForComposableRule 55 | .setContent(composable) 56 | .composeView 57 | 58 | dropshotsRule.assertSnapshot( 59 | view = composeView, 60 | bitmapCaptureMethod = dropshotsConfig.bitmapCaptureMethod, 61 | name = name, 62 | filePath = dropshotsConfig.filePath, 63 | ) 64 | } 65 | 66 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable = apply { 67 | if (config is DropshotsConfig) { 68 | dropshotsConfig = config 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/activityscenario/RobolectricActivityScenarioForComposableRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.activityscenario 2 | 3 | import android.app.Activity 4 | import androidx.annotation.ColorInt 5 | import androidx.compose.ui.platform.ComposeView 6 | import androidx.compose.ui.test.junit4.ComposeTestRule 7 | import androidx.compose.ui.test.junit4.createEmptyComposeRule 8 | import org.junit.rules.ExternalResource 9 | import org.junit.runner.Description 10 | import org.junit.runners.model.Statement 11 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 12 | import sergio.sastre.uitesting.utils.activityscenario.ComposableConfigItem 13 | import sergio.sastre.uitesting.utils.utils.waitForActivity 14 | import sergio.sastre.uitesting.utils.utils.waitForComposeView 15 | 16 | /** 17 | * Returns an ActivityScenario whose activity is configured with the given parameters 18 | * 19 | * WARNING: Do not use together with @Config(qualifiers = "my_qualifiers") to avoid 20 | * any misbehaviour 21 | */ 22 | class RobolectricActivityScenarioForComposableRule( 23 | deviceScreen: DeviceScreen? = null, 24 | config: ComposableConfigItem? = null, 25 | @ColorInt backgroundColor: Int? = null, 26 | ) : ExternalResource() { 27 | 28 | private val emptyComposeRule = createEmptyComposeRule() 29 | 30 | val composeRule: ComposeTestRule by lazy { 31 | activityScenario.waitForActivity() 32 | emptyComposeRule.apply { waitForIdle() } 33 | } 34 | 35 | val activityScenario = 36 | RobolectricActivityScenarioConfigurator.ForComposable() 37 | .apply { 38 | deviceScreen?.also { deviceScreen -> setDeviceScreen(deviceScreen) } 39 | config?.orientation?.also { orientation -> setInitialOrientation(orientation) } 40 | config?.locale?.also { locale -> setLocale(locale) } 41 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 42 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight) } 43 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 44 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 45 | }.launchConfiguredActivity(backgroundColor) 46 | 47 | val activity: Activity by lazy { activityScenario.waitForActivity() } 48 | 49 | val composeView: ComposeView by lazy { 50 | emptyComposeRule.waitForIdle() 51 | activity.waitForComposeView() 52 | } 53 | 54 | override fun apply(base: Statement?, description: Description?): Statement { 55 | // we need to call super, otherwise after() will not be called 56 | val externalResourceStatement = super.apply(base, description) 57 | return emptyComposeRule.apply(externalResourceStatement, description) 58 | } 59 | 60 | override fun after() { 61 | activityScenario.close() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![](https://jitpack.io/v/sergio-sastre/AndroidUiTestingUtils.svg)](https://jitpack.io/#sergio-sastre/AndroidUiTestingUtils)
2 |
3 | 4 | 5 | 6 | 7 | #

Android UI testing utils

8 | 9 |

10 | 11 |

12 | 13 | A set of *TestRules*, *ActivityScenarios* and utils to facilitate UI & screenshot testing under 14 | certain configurations, independent of the UI testing libraries you are using. 15 |
16 |

17 | For screenshot testing, it supports: 18 | - **Jetpack Compose** 19 | - **android Views** (e.g. custom Views, ViewHolders, etc.) 20 | - **Activities** 21 | - **Fragments** 22 | - [**Robolectric**](https://sergio-sastre.gitbook.io/androiduitestingutils/setup/robolectric-setup) 23 | - [**Cross-library** & **Shared screenshot testing**](https://sergio-sastre.gitbook.io/androiduitestingutils/setup/cross-library-setup) i.e. same test running either on device or on JVM. 24 |
25 | 26 | This library enables you to easily change the following configurations in your UI tests: 27 | 28 | 1. Locale (also [Pseudolocales](https://developer.android.com/guide/topics/resources/pseudolocales#:~:text=4%20or%20earlier%3A-,On%20the%20device%2C%20open%20the%20Settings%20app%20and%20tap%20Languages,language%20(see%20figure%203)) **en_XA** & **ar_XB**) 29 | 1. App Locale (i.e. per-app language preference) 30 | 2. System Locale 31 | 2. Custom themes 32 | 3. Dark mode / Day-Night mode 33 | 4. Orientation 34 | 5. Font size 35 | 6. Display size 36 | 7. Accessibility (e.g. high contrast text, bold text) 37 | 38 | Wondering why verifying our design under these configurations is important? I've got you covered: 39 | 40 | 🎨 [Design a pixel perfect Android app](https://sergiosastre.hashnode.dev/design-a-pixel-perfect-android-app-with-screenshot-testing) 41 |

42 | 43 | ## Documentation 44 | Check out [this library's documentation page](https://sergio-sastre.gitbook.io/androiduitestingutils/) to see how to use it, including code and ready-to-run examples 45 | 46 | ## Sponsors 47 | 48 | Thanks to [Screenshotbot](https://screenshotbot.io) for their support! 49 | [](https://screenshotbot.io) 50 | 51 | By using Screenshotbot instead of the in-build record/verify modes provided by most screenshot 52 | libraries, you'll give your colleages a better developer experience, since they will not be required 53 | to manually record screenshots after every run, instead getting notifications on their Pull 54 | Requests. 55 |
56 | 57 |

58 | Android UI testing utils 59 | logo modified from one by Freepik - Flaticon 60 | 61 | -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/crosslibrary/testrules/implementations/shared/SharedScreenshotLibraryTestRuleForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.crosslibrary.testrules.implementations.shared 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.view.View 6 | import androidx.recyclerview.widget.RecyclerView 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 10 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 11 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForView 12 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.providers.ScreenshotLibraryTestRuleForViewProvider 13 | import java.util.* 14 | 15 | abstract class SharedScreenshotLibraryTestRuleForView ( 16 | override val config: ScreenshotConfigForView, 17 | ) : ScreenshotTestRuleForView(config), ScreenshotLibraryTestRuleForViewProvider { 18 | 19 | private val factory: ScreenshotTestRuleForView by lazy { 20 | if (isRunningOnJvm()) { 21 | getJvmScreenshotLibraryTestRule(config) 22 | } else { 23 | getInstrumentedScreenshotLibraryTestRule(config) 24 | } 25 | } 26 | 27 | abstract fun getJvmScreenshotLibraryTestRule(config: ScreenshotConfigForView): ScreenshotTestRuleForView 28 | 29 | abstract fun getInstrumentedScreenshotLibraryTestRule(config: ScreenshotConfigForView): ScreenshotTestRuleForView 30 | 31 | private fun isRunningOnJvm(): Boolean = 32 | System.getProperty("java.runtime.name") 33 | ?.lowercase(Locale.getDefault()) 34 | ?.contains("android") != true 35 | 36 | override fun apply(base: Statement?, description: Description?): Statement { 37 | return factory.apply(base, description) 38 | } 39 | 40 | override val context: Context 41 | get() = factory.context 42 | 43 | override fun inflate(layoutId: Int): View = 44 | factory.inflate(layoutId) 45 | 46 | override fun waitForMeasuredView(actionToDo: () -> View): View = 47 | factory.waitForMeasuredView(actionToDo) 48 | 49 | override fun waitForMeasuredDialog(actionToDo: () -> Dialog): Dialog = 50 | factory.waitForMeasuredDialog(actionToDo) 51 | 52 | override fun waitForMeasuredViewHolder(actionToDo: () -> RecyclerView.ViewHolder): RecyclerView.ViewHolder = 53 | factory.waitForMeasuredViewHolder(actionToDo) 54 | 55 | override fun snapshotDialog(name: String?, dialog: Dialog) { 56 | factory.snapshotDialog(name, dialog) 57 | } 58 | 59 | override fun snapshotView(name: String?, view: View) { 60 | factory.snapshotView(name, view) 61 | } 62 | 63 | override fun snapshotViewHolder(name: String?, viewHolder: RecyclerView.ViewHolder) { 64 | factory.snapshotViewHolder(name, viewHolder) 65 | } 66 | 67 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForView { 68 | return factory.configure(config) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /android-testify/src/main/java/sergio/sastre/uitesting/android_testify/screenshotscenario/ScreenshotScenarioRuleExt.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.android_testify.screenshotscenario 2 | 3 | import android.app.Activity 4 | import android.graphics.Bitmap 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.core.view.drawToBitmap 8 | import androidx.test.platform.app.InstrumentationRegistry 9 | import dev.testify.TestDescription 10 | import dev.testify.TestifyFeatures 11 | import dev.testify.scenario.ScreenshotScenarioRule 12 | import dev.testify.testDescription 13 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 14 | import sergio.sastre.uitesting.utils.utils.drawToBitmapWithElevation 15 | import sergio.sastre.uitesting.utils.utils.waitForMeasuredView 16 | 17 | fun ScreenshotScenarioRule.assertSame(name: String?) { 18 | if (name != null) { 19 | // this is how to change the name of the screenshot file 20 | InstrumentationRegistry.getInstrumentation().testDescription = TestDescription( 21 | methodName = name, 22 | testClass = InstrumentationRegistry.getInstrumentation().testDescription.testClass 23 | ) 24 | } 25 | assertSame() 26 | } 27 | 28 | fun ScreenshotScenarioRule.waitForIdleSync(): ScreenshotScenarioRule = apply { 29 | InstrumentationRegistry.getInstrumentation().waitForIdleSync() 30 | } 31 | 32 | internal fun ScreenshotScenarioRule.setDialogViewUnderTest( 33 | view: View, 34 | ): ScreenshotScenarioRule = apply { 35 | setScreenshotViewProvider { 36 | InstrumentationRegistry.getInstrumentation().run { 37 | runOnMainSync { 38 | (view.parent as ViewGroup?)?.removeAllViews() 39 | } 40 | waitForIdleSync() 41 | 42 | runOnMainSync { 43 | (activity.window.decorView as ViewGroup).addView(view) 44 | } 45 | waitForIdleSync() 46 | } 47 | waitForMeasuredView { view } 48 | } 49 | } 50 | 51 | internal fun ScreenshotScenarioRule.setViewUnderTest( 52 | view: View, 53 | ): ScreenshotScenarioRule = apply { 54 | setScreenshotViewProvider { 55 | waitForMeasuredView { view } 56 | } 57 | } 58 | 59 | internal fun ScreenshotScenarioRule.setBitmapCaptureMethod( 60 | bitmapCaptureMethod: BitmapCaptureMethod?, 61 | ): ScreenshotScenarioRule = apply { 62 | when (bitmapCaptureMethod) { 63 | is BitmapCaptureMethod.Canvas -> { 64 | fun canvas(activity: Activity, targetView: View?): Bitmap? { 65 | return targetView?.drawToBitmap(bitmapCaptureMethod.config) 66 | } 67 | configure { captureMethod = ::canvas } 68 | } 69 | 70 | is BitmapCaptureMethod.PixelCopy -> { 71 | fun pixelCopy(activity: Activity, targetView: View?): Bitmap? { 72 | return targetView?.drawToBitmapWithElevation( 73 | activity = activity, 74 | config = bitmapCaptureMethod.config 75 | ) 76 | } 77 | configure { captureMethod = ::pixelCopy } 78 | } 79 | 80 | null -> { /*no-op*/ } 81 | } 82 | } 83 | 84 | fun ScreenshotScenarioRule.generateDiffs( 85 | generate: Boolean, 86 | ): ScreenshotScenarioRule = apply { 87 | if (generate) { 88 | TestifyFeatures.GenerateDiffs.setEnabled(true) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /roborazzi/src/main/java/sergio/sastre/uitesting/roborazzi/RoborazziScreenshotTestRuleForComposable.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.roborazzi 2 | 3 | import android.view.View 4 | import androidx.compose.runtime.Composable 5 | import com.github.takahirom.roborazzi.ExperimentalRoborazziApi 6 | import com.github.takahirom.roborazzi.captureRoboImage 7 | import org.junit.runner.Description 8 | import org.junit.runners.model.Statement 9 | import sergio.sastre.uitesting.robolectric.activityscenario.RobolectricActivityScenarioForComposableRule 10 | import sergio.sastre.uitesting.roborazzi.config.RoborazziSharedTestAdapter 11 | import sergio.sastre.uitesting.mapper.roborazzi.RoborazziConfig 12 | import sergio.sastre.uitesting.mapper.roborazzi.wrapper.CaptureType 13 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 14 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 15 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForComposable 16 | 17 | class RoborazziScreenshotTestRuleForComposable( 18 | override val config: ScreenshotConfigForComposable = ScreenshotConfigForComposable(), 19 | ) : ScreenshotTestRuleForComposable(config) { 20 | 21 | private val activityScenarioRule: RobolectricActivityScenarioForComposableRule by lazy { 22 | RobolectricActivityScenarioForComposableRule( 23 | config = config.toComposableConfig(), 24 | deviceScreen = roborazziAdapter.asDeviceScreen(), 25 | backgroundColor = roborazziConfig.backgroundColor, 26 | ) 27 | } 28 | 29 | private val roborazziAdapter: RoborazziSharedTestAdapter by lazy { 30 | RoborazziSharedTestAdapter(roborazziConfig) 31 | } 32 | 33 | private var roborazziConfig: RoborazziConfig = RoborazziConfig() 34 | 35 | private val filePathGenerator: FilePathGenerator = FilePathGenerator() 36 | 37 | override fun apply(base: Statement?, description: Description?): Statement = 38 | activityScenarioRule.apply(base, description) 39 | 40 | override fun snapshot(composable: @Composable () -> Unit) { 41 | snapshot(null, composable) 42 | } 43 | 44 | override fun snapshot(name: String?, composable: @Composable () -> Unit) { 45 | val view = activityScenarioRule 46 | .setContent { composable() } 47 | .composeView 48 | snapshotView(name, view) 49 | } 50 | 51 | private fun snapshotView(name: String?, view: View) { 52 | @OptIn(ExperimentalRoborazziApi::class) 53 | when (roborazziConfig.roborazziOptions.captureType is CaptureType.Dump) { 54 | // Dump does not support Bitmap.captureRoboImage 55 | true -> view 56 | .captureRoboImage( 57 | filePath = filePathGenerator(roborazziConfig.filePath, name), 58 | roborazziOptions = roborazziAdapter.asRoborazziOptions(), 59 | ) 60 | 61 | false -> view 62 | .drawToBitmap(roborazziConfig.bitmapCaptureMethod) 63 | .captureRoboImage( 64 | filePath = filePathGenerator.invoke(roborazziConfig.filePath, name), 65 | roborazziOptions = roborazziAdapter.asRoborazziOptions(), 66 | ) 67 | } 68 | } 69 | 70 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForComposable = apply { 71 | if (config is RoborazziConfig) { 72 | roborazziConfig = config 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/PaparazziScreenshotTestRuleForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.view.View 6 | import androidx.recyclerview.widget.RecyclerView 7 | import app.cash.paparazzi.Paparazzi 8 | import org.junit.runner.Description 9 | import org.junit.runners.model.Statement 10 | import sergio.sastre.uitesting.paparazzi.config.PaparazziForViewTestRuleBuilder 11 | import sergio.sastre.uitesting.mapper.paparazzi.PaparazziConfig 12 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 13 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 14 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForView 15 | 16 | class PaparazziScreenshotTestRuleForView( 17 | override val config: ScreenshotConfigForView = ScreenshotConfigForView(), 18 | ) : ScreenshotTestRuleForView(config) { 19 | 20 | private var paparazziConfig = PaparazziConfig() 21 | private var originalContext: Context? = null 22 | 23 | private val paparazziTestRule: Paparazzi by lazy { 24 | PaparazziForViewTestRuleBuilder() 25 | .applyPaparazziConfig(paparazziConfig) 26 | .applyScreenshotConfig(config) 27 | .build() 28 | } 29 | 30 | override fun apply(base: Statement, description: Description): Statement = 31 | paparazziTestRule.apply(base, description) 32 | 33 | override val context: Context 34 | get() { 35 | if (originalContext == null) { 36 | originalContext = paparazziTestRule.context.apply { 37 | setFontWeight(config.fontWeight) 38 | setDisplaySize(config.displaySize) 39 | } 40 | } 41 | return requireNotNull(originalContext) 42 | } 43 | 44 | 45 | override fun inflate(layoutId: Int): View { 46 | // FontWeight must be applied before inflating the corresponding View to take effect 47 | context 48 | return paparazziTestRule.inflate(layoutId) 49 | } 50 | 51 | override fun waitForMeasuredView(actionToDo: () -> View): View = actionToDo() 52 | 53 | override fun waitForMeasuredDialog(actionToDo: () -> Dialog): Dialog = actionToDo() 54 | 55 | override fun waitForMeasuredViewHolder(actionToDo: () -> RecyclerView.ViewHolder): RecyclerView.ViewHolder = 56 | actionToDo() 57 | 58 | override fun snapshotDialog(name: String?, dialog: Dialog) { 59 | paparazziTestRule.snapshot( 60 | name = name, 61 | view = dialog.window!!.decorView, 62 | offsetMillis = paparazziConfig.snapshotViewOffsetMillis, 63 | ) 64 | } 65 | 66 | override fun snapshotView(name: String?, view: View) { 67 | paparazziTestRule.snapshot( 68 | name = name, 69 | view = view, 70 | offsetMillis = paparazziConfig.snapshotViewOffsetMillis, 71 | ) 72 | } 73 | 74 | override fun snapshotViewHolder(name: String?, viewHolder: RecyclerView.ViewHolder) { 75 | paparazziTestRule.snapshot( 76 | name = name, 77 | view = viewHolder.itemView, 78 | offsetMillis = paparazziConfig.snapshotViewOffsetMillis, 79 | ) 80 | } 81 | 82 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForView = apply { 83 | if (config is PaparazziConfig) { 84 | paparazziConfig = config 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /shot/src/main/java/sergio/sastre/uitesting/shot/ShotScreenshotTestRuleForView.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.shot 2 | 3 | import android.app.Dialog 4 | import android.content.Context 5 | import android.view.View 6 | import androidx.recyclerview.widget.RecyclerView.ViewHolder 7 | import com.karumi.shot.ScreenshotTest 8 | import org.junit.runner.Description 9 | import org.junit.runners.model.Statement 10 | import sergio.sastre.uitesting.utils.activityscenario.ActivityScenarioForViewRule 11 | import sergio.sastre.uitesting.utils.crosslibrary.config.LibraryConfig 12 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 13 | import sergio.sastre.uitesting.utils.crosslibrary.testrules.ScreenshotTestRuleForView 14 | import sergio.sastre.uitesting.utils.utils.waitForMeasuredView as androidUiTestingUtilsMeasuredView 15 | import sergio.sastre.uitesting.utils.utils.waitForMeasuredViewHolder as androidUiTestingUtilsMeasuredViewHolder 16 | import sergio.sastre.uitesting.utils.utils.waitForMeasuredDialog as androidUiTestingUtilsMeasuredDialog 17 | 18 | class ShotScreenshotTestRuleForView( 19 | override val config: ScreenshotConfigForView = ScreenshotConfigForView(), 20 | ) : ScreenshotTestRuleForView(config), ScreenshotTest { 21 | 22 | private val activityScenarioForViewRule by lazy { 23 | ActivityScenarioForViewRule( 24 | config = config.toViewConfig(), 25 | backgroundColor = shotConfig.backgroundColor, 26 | ) 27 | } 28 | 29 | private var shotConfig: ShotConfig = ShotConfig() 30 | 31 | override fun apply(base: Statement?, description: Description?): Statement = 32 | activityScenarioForViewRule.apply(base, description) 33 | 34 | override val context: Context 35 | get() = activityScenarioForViewRule.activity 36 | 37 | override fun inflate(layoutId: Int): View = 38 | activityScenarioForViewRule.inflateAndWaitForIdle(layoutId) 39 | 40 | override fun waitForMeasuredView(actionToDo: () -> View): View = 41 | androidUiTestingUtilsMeasuredView { actionToDo() } 42 | 43 | override fun waitForMeasuredDialog(actionToDo: () -> Dialog): Dialog = 44 | androidUiTestingUtilsMeasuredDialog { actionToDo() } 45 | 46 | override fun waitForMeasuredViewHolder(actionToDo: () -> ViewHolder): ViewHolder = 47 | androidUiTestingUtilsMeasuredViewHolder { actionToDo() } 48 | 49 | override fun snapshotDialog(name: String?, dialog: Dialog) { 50 | ScreenshotTaker(this).compareSnapshot( 51 | dialog = dialog, 52 | bitmapCaptureMethod = shotConfig.bitmapCaptureMethod, 53 | name = name, 54 | maxPixels = shotConfig.viewMaxPixels 55 | ) 56 | } 57 | 58 | override fun snapshotView(name: String?, view: View) { 59 | ScreenshotTaker(this).compareSnapshot( 60 | view = view, 61 | bitmapCaptureMethod = shotConfig.bitmapCaptureMethod, 62 | name = name, 63 | maxPixels = shotConfig.viewMaxPixels 64 | ) 65 | } 66 | 67 | override fun snapshotViewHolder(name: String?, viewHolder: ViewHolder) { 68 | ScreenshotTaker(this).compareSnapshot( 69 | viewHolder = viewHolder, 70 | bitmapCaptureMethod = shotConfig.bitmapCaptureMethod, 71 | name = name, 72 | maxPixels = shotConfig.viewMaxPixels 73 | ) 74 | } 75 | 76 | override fun configure(config: LibraryConfig): ScreenshotTestRuleForView = apply { 77 | if (config is ShotConfig) { 78 | shotConfig = config 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /robolectric/src/main/java/sergio/sastre/uitesting/robolectric/activityscenario/RobolectricActivityScenarioForActivityRule.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.robolectric.activityscenario 2 | 3 | import android.app.Activity 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.view.View 7 | import androidx.test.core.app.ActivityScenario 8 | import org.junit.rules.ExternalResource 9 | import org.junit.runner.Description 10 | import org.junit.runners.model.Statement 11 | import sergio.sastre.uitesting.robolectric.config.screen.DeviceScreen 12 | import sergio.sastre.uitesting.utils.activityscenario.ActivityConfigItem 13 | import sergio.sastre.uitesting.utils.utils.waitForActivity 14 | 15 | class RobolectricActivityScenarioForActivityRule private constructor() : 16 | ExternalResource() { 17 | 18 | lateinit var activityScenario: ActivityScenario 19 | private set 20 | 21 | val activity: Activity by lazy { activityScenario.waitForActivity() } 22 | 23 | val rootView: View 24 | get() = this.activity.window.decorView 25 | 26 | /** 27 | * Returns an ActivityScenario whose activity is configured with the given parameters 28 | * 29 | * WARNING: Do not use together with @Config(qualifiers = "my_qualifiers") to avoid 30 | * any misbehaviour 31 | */ 32 | constructor( 33 | clazz: Class, 34 | intent: Intent, 35 | activityOptions: Bundle? = null, 36 | deviceScreen: DeviceScreen? = null, 37 | config: ActivityConfigItem? = null, 38 | ) : this() { 39 | activityScenario = 40 | RobolectricActivityScenarioConfigurator.ForActivity() 41 | .applyDeviceScreen(deviceScreen) 42 | .applyConfig(config) 43 | .launch(clazz, intent, activityOptions) 44 | } 45 | 46 | /** 47 | * Returns an ActivityScenario whose activity is configured with the given parameters 48 | * 49 | * WARNING: Do not use together with @Config(qualifiers = "my_qualifiers") to avoid 50 | * any misbehaviour 51 | */ 52 | constructor( 53 | clazz: Class, 54 | deviceScreen: DeviceScreen? = null, 55 | config: ActivityConfigItem? = null, 56 | ) : this() { 57 | activityScenario = 58 | RobolectricActivityScenarioConfigurator.ForActivity() 59 | .applyDeviceScreen(deviceScreen) 60 | .applyConfig(config) 61 | .launch(clazz) 62 | } 63 | 64 | override fun apply(base: Statement?, description: Description?): Statement { 65 | return super.apply(base, description) 66 | } 67 | 68 | private fun RobolectricActivityScenarioConfigurator.ForActivity.applyConfig( 69 | config: ActivityConfigItem? = null, 70 | ): RobolectricActivityScenarioConfigurator.ForActivity = apply { 71 | config?.orientation?.also { orientation -> setOrientation(orientation) } 72 | config?.systemLocale?.also { locale -> setSystemLocale(locale) } 73 | config?.uiMode?.also { uiMode -> setUiMode(uiMode) } 74 | config?.fontWeight?.also { fontWeight -> setFontWeight(fontWeight)} 75 | config?.fontSize?.also { fontSize -> setFontSize(fontSize) } 76 | config?.displaySize?.also { displaySize -> setDisplaySize(displaySize) } 77 | } 78 | 79 | private fun RobolectricActivityScenarioConfigurator.ForActivity.applyDeviceScreen( 80 | deviceScreen: DeviceScreen? = null, 81 | ): RobolectricActivityScenarioConfigurator.ForActivity = apply { 82 | deviceScreen?.also { screen -> setDeviceScreen(screen) } 83 | } 84 | 85 | override fun after() { 86 | activityScenario.close() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /android-testify/src/main/java/sergio/sastre/uitesting/android_testify/screenshotscenario/ScreenshotScenarioRuleForFragment.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.android_testify.screenshotscenario 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | import androidx.activity.viewModels 6 | import androidx.annotation.RestrictTo 7 | import androidx.fragment.app.Fragment 8 | import androidx.fragment.app.FragmentActivity 9 | import androidx.fragment.app.FragmentFactory 10 | import androidx.lifecycle.ViewModel 11 | import androidx.lifecycle.ViewModelProvider 12 | import androidx.test.core.app.ActivityScenario 13 | import dev.testify.core.TestifyConfiguration 14 | import dev.testify.scenario.ScreenshotScenarioRule 15 | 16 | class ScreenshotScenarioRuleForFragment( 17 | configuration: TestifyConfiguration, 18 | enableReporter: Boolean = false, 19 | private val fragmentClass: Class, 20 | private val fragmentArgs: Bundle? = null, 21 | private val factory: FragmentFactory? = null, 22 | ) : ScreenshotScenarioRule( 23 | enableReporter = enableReporter, 24 | configuration = configuration 25 | ) { 26 | 27 | private companion object { 28 | const val FRAGMENT_TAG = "Android-Testify-Fragment" 29 | } 30 | 31 | override fun withScenario(scenario: ActivityScenario): ScreenshotScenarioRule { 32 | return super.withScenario(scenario).also { setFragmentForScreenshot() } 33 | } 34 | 35 | private fun setFragmentForScreenshot(){ 36 | setViewModifications { 37 | if (factory != null) { 38 | FragmentFactoryHolderViewModel.getInstance(activity as FragmentActivity).fragmentFactory = factory 39 | (activity as FragmentActivity).supportFragmentManager.fragmentFactory = factory 40 | } 41 | 42 | val fragment = (activity as FragmentActivity).supportFragmentManager.fragmentFactory 43 | .instantiate(requireNotNull(fragmentClass.classLoader), fragmentClass.name) 44 | 45 | if (fragmentArgs != null) { 46 | fragment.arguments = fragmentArgs 47 | } 48 | 49 | (activity as FragmentActivity).supportFragmentManager.beginTransaction() 50 | .add( 51 | android.R.id.content, 52 | fragment, 53 | FRAGMENT_TAG 54 | ) 55 | .commitNow() 56 | 57 | // by default, the fragment is what we screenshot 58 | }.setScreenshotViewProvider { 59 | (activity as FragmentActivity).supportFragmentManager.findFragmentByTag(FRAGMENT_TAG)!!.requireView() 60 | } 61 | } 62 | 63 | @RestrictTo(RestrictTo.Scope.LIBRARY) 64 | internal class FragmentFactoryHolderViewModel : ViewModel() { 65 | var fragmentFactory: FragmentFactory? = null 66 | 67 | override fun onCleared() { 68 | super.onCleared() 69 | fragmentFactory = null 70 | } 71 | 72 | companion object { 73 | fun getInstance(activity: FragmentActivity): FragmentFactoryHolderViewModel { 74 | val viewModel: FragmentFactoryHolderViewModel by activity.viewModels { 75 | object : ViewModelProvider.Factory { 76 | @Suppress("UNCHECKED_CAST") 77 | override fun create(modelClass: Class): T { 78 | val viewModel = 79 | FragmentFactoryHolderViewModel() 80 | return viewModel as T 81 | } 82 | } 83 | } 84 | return viewModel 85 | } 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /utils/src/main/java/sergio/sastre/uitesting/utils/activityscenario/orientation/OrientationHelper.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.utils.activityscenario.orientation 2 | 3 | import android.app.Activity 4 | import android.content.pm.ActivityInfo 5 | import android.graphics.Point 6 | import androidx.test.espresso.Espresso 7 | import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry 8 | import androidx.test.runner.lifecycle.Stage 9 | import java.lang.RuntimeException 10 | import java.util.concurrent.CountDownLatch 11 | import java.util.concurrent.TimeUnit 12 | 13 | /** 14 | * Class responsible for waiting till the activity actually rotates 15 | * 16 | * Taken from : https://github.com/Shopify/android-testify/issues 17 | */ 18 | internal class OrientationHelper( 19 | private val activity: T 20 | ) { 21 | var deviceOrientation: Int = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED 22 | var requestedOrientation: Int? = null 23 | private lateinit var lifecycleLatch: CountDownLatch 24 | 25 | internal fun setLayoutOrientation() { 26 | 27 | // Set the orientation based on how the activity was launched 28 | deviceOrientation = if (activity.isLandscape) 29 | ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE 30 | else 31 | ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT 32 | 33 | this.requestedOrientation?.let { 34 | if (!activity.isRequestedOrientation(it)) { 35 | activity.changeOrientation(it) 36 | 37 | // Re-capture the orientation based on user requested value 38 | deviceOrientation = it 39 | } 40 | } 41 | this.requestedOrientation = null 42 | } 43 | 44 | private val Activity.isLandscape: Boolean 45 | get() { 46 | val size = Point(-1, -1) 47 | this.windowManager?.defaultDisplay?.getRealSize(size) 48 | return size.y < size.x 49 | } 50 | 51 | /** 52 | * Check if the activity's current orientation matches what was requested 53 | */ 54 | private fun Activity.isRequestedOrientation(requestedOrientation: Int): Boolean { 55 | return (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE && this.isLandscape) || 56 | (requestedOrientation != ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE && !this.isLandscape) 57 | } 58 | 59 | /** 60 | * Lifecycle callback. Wait for the activity under test to completely resume after configuration change. 61 | */ 62 | private fun lifecycleCallback(activity: Activity, stage: Stage) { 63 | if (activity::class.java == this.activity::class.java) { 64 | if (stage == Stage.RESUMED) { 65 | lifecycleLatch.countDown() 66 | } 67 | } 68 | } 69 | 70 | private fun Activity.changeOrientation(requestedOrientation: Int) { 71 | ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(::lifecycleCallback) 72 | 73 | Espresso.onIdle() 74 | 75 | val rotationLatch = CountDownLatch(1) 76 | lifecycleLatch = CountDownLatch(1) 77 | 78 | this.runOnUiThread { 79 | this.requestedOrientation = requestedOrientation 80 | rotationLatch.countDown() 81 | } 82 | 83 | // Wait for the rotation request to be made 84 | if (!rotationLatch.await(30, TimeUnit.SECONDS)) { 85 | throw RuntimeException("Failed to apply requested rotation.") 86 | } 87 | 88 | try { 89 | // Wait for the activity to fully resume 90 | if (!lifecycleLatch.await(30, TimeUnit.SECONDS)) { 91 | throw RuntimeException("Activity did not resume.") 92 | } 93 | } finally { 94 | ActivityLifecycleMonitorRegistry.getInstance() 95 | .removeLifecycleCallback(::lifecycleCallback) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /dropshots/src/main/java/sergio/sastre/uitesting/dropshots/ScreenshotTaker.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.dropshots 2 | 3 | import android.app.Dialog 4 | import android.graphics.Bitmap 5 | import android.view.View 6 | import androidx.core.view.drawToBitmap 7 | import com.dropbox.dropshots.Dropshots 8 | import org.junit.rules.TestRule 9 | import org.junit.runner.Description 10 | import org.junit.runners.model.Statement 11 | import sergio.sastre.uitesting.utils.crosslibrary.config.BitmapCaptureMethod 12 | import sergio.sastre.uitesting.utils.utils.drawToBitmap 13 | import sergio.sastre.uitesting.utils.utils.drawToBitmapWithElevation 14 | 15 | /** 16 | * Wrapper on Dropshots to record/verify screenshots 17 | */ 18 | internal class ScreenshotTaker( 19 | private val dropshots: Dropshots, 20 | ) : TestRule { 21 | override fun apply(base: Statement, description: Description): Statement = 22 | dropshots.apply(base, description) 23 | 24 | fun assertSnapshot( 25 | view: View, 26 | bitmapCaptureMethod: BitmapCaptureMethod?, 27 | name: String?, 28 | filePath: String?, 29 | ) { 30 | when (bitmapCaptureMethod) { 31 | is BitmapCaptureMethod.Canvas -> 32 | assertSnapshot( 33 | bitmap = view.drawToBitmap(config = bitmapCaptureMethod.config), 34 | name = name, 35 | filePath = filePath, 36 | ) 37 | is BitmapCaptureMethod.PixelCopy -> 38 | assertSnapshot( 39 | bitmap = view.drawToBitmapWithElevation(config = bitmapCaptureMethod.config), 40 | name = name, 41 | filePath = filePath, 42 | ) 43 | null -> assertSnapshot( 44 | view = view, 45 | name = name, 46 | filePath = filePath, 47 | ) 48 | } 49 | } 50 | 51 | fun assertSnapshot( 52 | dialog: Dialog, 53 | bitmapCaptureMethod: BitmapCaptureMethod?, 54 | name: String?, 55 | filePath: String?, 56 | ) { 57 | when (bitmapCaptureMethod) { 58 | is BitmapCaptureMethod.Canvas -> 59 | assertSnapshot( 60 | bitmap = dialog.drawToBitmap(config = bitmapCaptureMethod.config), 61 | name = name, 62 | filePath = filePath, 63 | ) 64 | is BitmapCaptureMethod.PixelCopy -> 65 | assertSnapshot( 66 | bitmap = dialog.drawToBitmapWithElevation(config = bitmapCaptureMethod.config), 67 | name = name, 68 | filePath = filePath, 69 | ) 70 | null -> assertSnapshot( 71 | view = dialog.window!!.decorView, 72 | name = name, 73 | filePath = filePath, 74 | ) 75 | } 76 | } 77 | 78 | private fun assertSnapshot(bitmap: Bitmap, name: String?, filePath: String?) { 79 | if (name != null) { 80 | dropshots.assertSnapshot( 81 | bitmap = bitmap, 82 | name = name, 83 | filePath = filePath 84 | ) 85 | } else { 86 | dropshots.assertSnapshot( 87 | bitmap = bitmap, 88 | filePath = filePath, 89 | ) 90 | } 91 | } 92 | 93 | private fun assertSnapshot(view: View, name: String?, filePath: String?) { 94 | if (name != null) { 95 | dropshots.assertSnapshot( 96 | view = view, 97 | name = name, 98 | filePath = filePath, 99 | ) 100 | } else { 101 | dropshots.assertSnapshot( 102 | view = view, 103 | filePath = filePath, 104 | ) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /paparazzi/src/main/java/sergio/sastre/uitesting/paparazzi/config/PaparazziScreenshotConfigAdapter.kt: -------------------------------------------------------------------------------- 1 | package sergio.sastre.uitesting.paparazzi.config 2 | 3 | import com.android.resources.NightMode 4 | import com.android.resources.ScreenOrientation 5 | import com.android.resources.ScreenOrientation.LANDSCAPE 6 | import com.android.resources.ScreenOrientation.PORTRAIT 7 | import com.android.resources.ScreenOrientation.SQUARE 8 | import sergio.sastre.uitesting.mapper.paparazzi.PaparazziConfig 9 | import sergio.sastre.uitesting.utils.common.Orientation 10 | import sergio.sastre.uitesting.utils.common.UiMode 11 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForComposable 12 | import sergio.sastre.uitesting.utils.crosslibrary.config.ScreenshotConfigForView 13 | 14 | internal class PaparazziScreenshotConfigAdapter( 15 | private val paparazziConfig: PaparazziConfig 16 | ) { 17 | fun getDeviceConfigFor(screenshotConfigForComposable: ScreenshotConfigForComposable): app.cash.paparazzi.DeviceConfig = 18 | PaparazziWrapperConfigAdapter(paparazziConfig).asPaparazziDeviceConfig().copy( 19 | orientation = screenshotConfigForComposable.orientation.toScreenOrientation(), 20 | nightMode = screenshotConfigForComposable.uiMode.toNightMode(), 21 | fontScale = screenshotConfigForComposable.fontScale.scale, 22 | locale = screenshotConfigForComposable.locale.toBC47Locale(), 23 | ).hackViewDimensionsToOrientation() 24 | 25 | fun getDeviceConfigFor(screenshotConfigForView: ScreenshotConfigForView): app.cash.paparazzi.DeviceConfig = 26 | PaparazziWrapperConfigAdapter(paparazziConfig).asPaparazziDeviceConfig().copy( 27 | orientation = screenshotConfigForView.orientation.toScreenOrientation(), 28 | nightMode = screenshotConfigForView.uiMode.toNightMode(), 29 | fontScale = screenshotConfigForView.fontSize.scale, 30 | locale = screenshotConfigForView.locale.toBC47Locale(), 31 | ).hackViewDimensionsToOrientation() 32 | 33 | /** 34 | * Paparazzi has sometimes problems with measuring views. Therefore, this uses a default 35 | * configuration that renders most views properly. 36 | * 37 | * Set the RenderingMode explicitly if that is not desired. 38 | */ 39 | private fun app.cash.paparazzi.DeviceConfig.hackViewDimensionsToOrientation() 40 | : app.cash.paparazzi.DeviceConfig { 41 | // If a Paparazzi rendering mode applies, 42 | // do not hack Height and Width to simulate orientation change 43 | if (paparazziConfig.renderingMode != null) { 44 | return copy(orientation = this.orientation) 45 | } 46 | 47 | // This is a hack when if using SHRINK, the ViewHolder dimensions are miscalculated 48 | // For that, one should use V_SCROLL + these values in DeviceConfig 49 | val old = copy() 50 | return when (orientation) { 51 | PORTRAIT -> copy( 52 | screenHeight = old.screenWidth, 53 | screenWidth = 1, 54 | orientation = LANDSCAPE, 55 | ) 56 | LANDSCAPE -> copy( 57 | screenWidth = old.screenHeight, 58 | screenHeight = 1, 59 | orientation = LANDSCAPE, 60 | ) 61 | SQUARE -> this 62 | } 63 | } 64 | 65 | private fun Orientation.toScreenOrientation(): ScreenOrientation = 66 | when (this) { 67 | Orientation.PORTRAIT -> PORTRAIT 68 | Orientation.LANDSCAPE -> LANDSCAPE 69 | } 70 | 71 | private fun String.toBC47Locale(): String = 72 | when { 73 | this == "en_XA" -> "en-rXA" 74 | this == "ar_XB" -> "ar-rXB" 75 | this.contains("-") -> "b+${this.replace(oldChar = '-', newChar = '+')}" 76 | else -> this 77 | } 78 | 79 | private fun UiMode.toNightMode(): NightMode = 80 | when (this) { 81 | UiMode.NIGHT -> NightMode.NIGHT 82 | UiMode.DAY -> NightMode.NOTNIGHT 83 | } 84 | } 85 | --------------------------------------------------------------------------------