diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 320412b2b..4ed62c25f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -192,6 +192,58 @@ jobs: name: designcompose_m2repo path: build/designcompose_m2repo/ + # These tests run on the local JVM and don't need the rust code, + # so it makes sense to put them in a seperate job + roborazzi: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@8ca2b8b2ece13480cda6dacd3511b49857a23c09 # v2.5.1 + with: + egress-policy: audit + + - uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v3.5.2 + + - name: Set up Java + uses: actions/setup-java@cd89f46ac9d01407894225f350157564c9c7cee2 # v3.12.0 + with: + distribution: "temurin" + java-version: "17" + + - name: Validate Roborazzi + uses: gradle/gradle-build-action@ef76a971e2fa3f867b617efd72f2fbd72cf6f8bc # v2.8.0 + with: + # Upload in the dependency-review workflow + dependency-graph: generate + arguments: verifyRoborazziDebug + + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: screenshot-diff + path: | + **/src/testDebug/roborazzi + retention-days: 30 + + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: screenshot-diff-reports + path: | + **/build/reports + retention-days: 30 + + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: screenshot-diff-test-results + path: | + **/build/test-results + retention-days: 30 + ########### Tutorial app tutorial-app: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 7281d2aa8..3a36f759d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,16 @@ To update the Design Switcher, temporarily set the `DISABLE_LIVE_MODE` flag in ` The Tutorial's DesignDoc is set to the main development Figma file, to assist in development of the app, but the committed serialized file is a separate file that presents a "welcome" page. This file's ID is `BX9UyUa5lkuSP3dEnqBdJf`. To update the file, fetch the `BX9UyUa5lkuSP3dEnqBdJf` file, then replace `reference-apps/tutorial/app/src/main/assets/figma/TutorialDoc_3z4xExq0INrL9vxPhj9tl7` with the serialized file. **The file name will remain the same**, the `TutorialDoc_3z4xExq0INrL9vxPhj9tl7` fill will contain the serialized `BX9UyUa5lkuSP3dEnqBdJf` file. The Tutorial app project has an AndroidIntegratedTest to ensure that the correct file is set, and it will be run as part of running the `./dev-scripts/test-all.sh` script. +## Roborazzi screenshot tests + +[Roborazzi](https://github.com/takahirom/roborazzi) is a new framework that allows for screenshot testing of Android Apps on your local system. It uses [Robolectric](https://github.com/robolectric/robolectric), the standard unit testing framework for Android, to render DesignCompose locally, allowing screenshots to be generated. The screenshots won't be one-to-one with actual Android devices, but they'll be very close and stable enough for changes to be detected. + +Roborazzi is implemented in .tests under `/src/debugTest`. The tests themselves will run if you run `./gradlew test`, but screenshots will only be checked if you run `./gradlew verifyRoborazziDebug`. This command has been added to the `./dev-scripts/test-all.sh` command, so you don't need to run it separately if you run test-all. + +If `verifyRoborazziDebug` fails then you can run `compareRoborazziDebug` to generate image diffs. If after reviewing these you determine that the change is acceptable you can regenerate the screenshots using `recordRoborazziDebug`. (Note that this will regenerate all images from all tests). + +More info can be found in [Roborazzi's readme](https://github.com/takahirom/roborazzi#apply-roborazzi-gradle-plugin). + ## Testing the standalone version of the Tutorial app The Tutorial app is currently part of two projects: The root project in the root of the repository, and the tutorial project in `reference-apps/tutorial`. The root project is the one that contains the entire SDK and our apps and is where you typically develop. The second project is the one that users following the Tutorial are directed to use. It fetches DesignCompose from gMaven, which means that it builds much faster and doesn't compile rust code (and doesn't require the rust SDK to be installed). This means that the standalone Tutorial needs some extra configuration if you want the standalone Tutorial project to use any unpublished changes to the libraries and plugin. diff --git a/dev-scripts/test-all.sh b/dev-scripts/test-all.sh index 9ad6d145d..3bff40ced 100755 --- a/dev-scripts/test-all.sh +++ b/dev-scripts/test-all.sh @@ -80,7 +80,7 @@ cd "$GIT_ROOT/plugins" || exit ./gradlew build cd "$GIT_ROOT" || exit -./gradlew build publishAllPublicationsToLocalDirRepository +./gradlew build publishAllPublicationsToLocalDirRepository verifyRoborazziDebug if [[ $run_emulator_tests == 1 ]]; then ./gradlew tabletAtdApi30Check -Pandroid.testoptions.manageddevices.emulator.gpu=swiftshader_indirect diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c14ae639d..0b5109969 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,8 @@ junit5 = "5.10.0" truth = "1.1.5" core = "1.10.1" activityKtx = "1.7.2" +robolectric = "4.10.3" +roborazzi = "1.6.0-alpha-3" [libraries] accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" } @@ -108,9 +110,14 @@ google-truth = { module = "com.google.truth:truth", version.ref = "truth" } junit-jupiter = {module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } androidx-core = { group = "androidx.core", name = "core", version.ref = "core" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +robolectric = {module = "org.robolectric:robolectric", version.ref = "robolectric"} +roborazzi = {module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi"} +roborazzi-compose = {module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi"} +roborazzi-junit = {module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi"} [plugins] designcompose = { id = "com.android.designcompose", version.ref = "designcompose" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } strictVersionMatcher = { id = "com.google.android.gms.strict-version-matcher-plugin", version.ref = "android-gms-strictVersionMatcher" } +roborazzi = {id = "io.github.takahirom.roborazzi", version.ref = "roborazzi"} diff --git a/reference-apps/helloworld/build.gradle.kts b/reference-apps/helloworld/build.gradle.kts index 355f52dcc..3e189e559 100644 --- a/reference-apps/helloworld/build.gradle.kts +++ b/reference-apps/helloworld/build.gradle.kts @@ -21,6 +21,7 @@ plugins { alias(libs.plugins.ksp) id("designcompose.conventions.base") alias(libs.plugins.designcompose) + alias(libs.plugins.roborazzi) } var applicationID = "com.android.designcompose.testapp.helloworld" @@ -71,6 +72,7 @@ android { } packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } } + testOptions { unitTests { isIncludeAndroidResources = true } } // For Roborazzi } dependencies { @@ -88,6 +90,14 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit) + testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.espresso.core) diff --git a/reference-apps/helloworld/src/testDebug/kotlin/RenderHelloWorld.kt b/reference-apps/helloworld/src/testDebug/kotlin/RenderHelloWorld.kt new file mode 100644 index 000000000..bfed1dd69 --- /dev/null +++ b/reference-apps/helloworld/src/testDebug/kotlin/RenderHelloWorld.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.designcompose.testapp.helloworld + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +// Enable Robolectric Native Graphics (RNG) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet) +class RenderHelloWorld { + @get:Rule val composeTestRule = createAndroidComposeRule() + + @get:Rule + val roborazziRule = + RoborazziRule( + composeRule = composeTestRule, + captureRoot = composeTestRule.onRoot(), + options = + RoborazziRule.Options( + RoborazziRule.CaptureType.LastImage(), + outputDirectoryPath = "src/testDebug/roborazzi" + ) + ) + + @Test + fun testHello() { + composeTestRule.setContent { HelloWorldDoc.mainFrame(name = "Testers!") } + composeTestRule.onNodeWithText("Testers!", substring = true).assertExists() + } +} diff --git a/reference-apps/helloworld/src/testDebug/roborazzi/com.android.designcompose.testapp.helloworld.ScreenshotTest.testHello.png b/reference-apps/helloworld/src/testDebug/roborazzi/com.android.designcompose.testapp.helloworld.ScreenshotTest.testHello.png new file mode 100644 index 000000000..ed2dd6e7c Binary files /dev/null and b/reference-apps/helloworld/src/testDebug/roborazzi/com.android.designcompose.testapp.helloworld.ScreenshotTest.testHello.png differ