├── 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/#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 |
--------------------------------------------------------------------------------