P8: Roborazzi golden image pipeline live
Records initial golden set + wires verifyRoborazziDebug into CI. Diffs
uploaded as artifact on failure. ScreenshotTests @Ignore removed.
Root cause of the prior RoboMonitoringInstrumentation:102 failure:
createComposeRule() launches ActivityScenarioRule<ComponentActivity>
which fires a MAIN/LAUNCHER intent, but the merged unit-test manifest
declares androidx.activity.ComponentActivity without a LAUNCHER filter,
so Robolectric's PM returns "Unable to resolve activity for Intent".
Fix: switch to the standalone captureRoboImage(path) { composable }
helper from roborazzi-compose, which registers
RoborazziTransparentActivity with Robolectric's shadow PackageManager
at runtime and bypasses ActivityScenario entirely.
Also pin roborazzi outputDir to src/androidUnitTest/roborazzi so
goldens live in git (not build/) and survive gradle clean.
36 goldens, 540KB total.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8
.github/workflows/android-ui-tests.yml
vendored
@@ -18,6 +18,14 @@ jobs:
|
|||||||
run: ./scripts/verify_test_tag_parity.sh
|
run: ./scripts/verify_test_tag_parity.sh
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: ./gradlew :composeApp:testDebugUnitTest
|
run: ./gradlew :composeApp:testDebugUnitTest
|
||||||
|
- name: Verify screenshot regressions
|
||||||
|
run: ./gradlew :composeApp:verifyRoborazziDebug
|
||||||
|
- name: Upload screenshot diffs on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: roborazzi-diffs
|
||||||
|
path: composeApp/build/outputs/roborazzi/
|
||||||
- name: Run instrumented tests (managed device)
|
- name: Run instrumented tests (managed device)
|
||||||
run: ./gradlew :composeApp:pixel7Api34DebugAndroidTest
|
run: ./gradlew :composeApp:pixel7Api34DebugAndroidTest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -222,3 +222,12 @@ compose.desktop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roborazzi screenshot-regression plugin (P8). Pin the golden-image
|
||||||
|
// output directory inside the test source set so goldens live in git
|
||||||
|
// alongside the tests themselves. Anything under build/ is gitignored
|
||||||
|
// and gets blown away by `gradle clean` — not where committed goldens
|
||||||
|
// belong.
|
||||||
|
roborazzi {
|
||||||
|
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||||
package com.tt.honeyDue.screenshot
|
package com.tt.honeyDue.screenshot
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -30,14 +29,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import com.github.takahirom.roborazzi.RoborazziOptions
|
||||||
import androidx.compose.ui.test.onRoot
|
|
||||||
import com.github.takahirom.roborazzi.RoborazziRule
|
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
import com.tt.honeyDue.ui.theme.AppThemes
|
import com.tt.honeyDue.ui.theme.AppThemes
|
||||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||||
import com.tt.honeyDue.ui.theme.ThemeColors
|
import com.tt.honeyDue.ui.theme.ThemeColors
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
@@ -45,7 +41,7 @@ import org.robolectric.annotation.Config
|
|||||||
import org.robolectric.annotation.GraphicsMode
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Roborazzi-driven screenshot regression tests (P8 scaffolding).
|
* Roborazzi-driven screenshot regression tests (P8).
|
||||||
*
|
*
|
||||||
* Runs entirely on the Robolectric unit-test classpath — no emulator
|
* Runs entirely on the Robolectric unit-test classpath — no emulator
|
||||||
* required. The goal is to catch accidental UI drift (colour, spacing,
|
* required. The goal is to catch accidental UI drift (colour, spacing,
|
||||||
@@ -56,40 +52,27 @@ import org.robolectric.annotation.GraphicsMode
|
|||||||
* (light / dark) = 36 images. This is a conservative baseline; the full
|
* (light / dark) = 36 images. This is a conservative baseline; the full
|
||||||
* 11-theme matrix would produce 132+ images and is deferred.
|
* 11-theme matrix would produce 132+ images and is deferred.
|
||||||
*
|
*
|
||||||
|
* Implementation notes:
|
||||||
|
* - We use the top-level `captureRoboImage(path) { composable }` form
|
||||||
|
* from roborazzi-compose. That helper registers
|
||||||
|
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
|
||||||
|
* PackageManager, so we don't need `createComposeRule()` /
|
||||||
|
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
|
||||||
|
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
|
||||||
|
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
|
||||||
|
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
|
||||||
|
* Roborazzi Gradle plugin picks up for record / verify / compare.
|
||||||
|
*
|
||||||
* Workflow:
|
* Workflow:
|
||||||
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
|
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
|
||||||
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
|
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
|
||||||
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
|
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
|
||||||
*
|
|
||||||
* We intentionally build *theme showcase* surfaces locally rather than
|
|
||||||
* invoking the full production screens (LoginScreen, TasksScreen, etc.)
|
|
||||||
* because those screens depend on DataManager/network state that can't
|
|
||||||
* be safely initialized from a Robolectric test. The showcases render
|
|
||||||
* the same material3 primitives the screens are composed from, so a
|
|
||||||
* colour/typography regression in Theme.kt will still be caught.
|
|
||||||
*/
|
*/
|
||||||
// TEMPORARILY DISABLED: Roborazzi runtime pipeline needs additional setup
|
|
||||||
// before screenshot tests can run green in CI. Enable via `@Ignore` removal
|
|
||||||
// once `recordRoborazziDebug` successfully generates the initial golden
|
|
||||||
// image set and CI is configured to run `verifyRoborazziDebug`.
|
|
||||||
@org.junit.Ignore("Roborazzi pipeline pending — see docs/screenshot-tests.md")
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
||||||
class ScreenshotTests {
|
class ScreenshotTests {
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createComposeRule()
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val roborazziRule = RoborazziRule(
|
|
||||||
composeRule = composeRule,
|
|
||||||
captureRoot = composeRule.onRoot(),
|
|
||||||
options = RoborazziRule.Options(
|
|
||||||
outputDirectoryPath = "build/outputs/roborazzi",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------- Login screen showcase ----------
|
// ---------- Login screen showcase ----------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -286,12 +269,14 @@ class ScreenshotTests {
|
|||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
composeRule.setContent {
|
captureRoboImage(
|
||||||
|
filePath = "build/outputs/roborazzi/$name.png",
|
||||||
|
roborazziOptions = RoborazziOptions(),
|
||||||
|
) {
|
||||||
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
|
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
composeRule.onRoot().captureRoboImage("build/outputs/roborazzi/$name.png")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_default_dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_default_light.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_midnight_dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_ocean_dark.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/login_ocean_light.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/profile_ocean_dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/profile_ocean_light.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/tasks_default_dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/tasks_default_light.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/tasks_midnight_dark.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/tasks_ocean_dark.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/tasks_ocean_light.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/themes_default_dark.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/themes_ocean_dark.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
composeApp/src/androidUnitTest/roborazzi/themes_ocean_light.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -50,19 +50,27 @@ consume every colour slot the real screens use.
|
|||||||
./gradlew :composeApp:compareRoborazziDebug
|
./gradlew :composeApp:compareRoborazziDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
Output lands under `composeApp/build/outputs/roborazzi/`.
|
Committed goldens live at
|
||||||
|
`composeApp/src/androidUnitTest/roborazzi/` — pinned there via the
|
||||||
|
`roborazzi { outputDir = ... }` block in `composeApp/build.gradle.kts`
|
||||||
|
so they survive `gradle clean`. Diffs and intermediate artefacts land
|
||||||
|
under `composeApp/build/outputs/roborazzi/` and are uploaded as a CI
|
||||||
|
artifact on failure (`android-ui-tests.yml → Upload screenshot diffs
|
||||||
|
on failure`).
|
||||||
|
|
||||||
## Golden-image workflow
|
## Golden-image workflow
|
||||||
|
|
||||||
Roborazzi goldens are *not* auto-committed. The workflow is:
|
Roborazzi goldens *are* committed alongside the tests — see
|
||||||
|
`composeApp/src/androidUnitTest/roborazzi/`. The workflow is:
|
||||||
|
|
||||||
1. Developer changes a composable (intentionally or otherwise).
|
1. Developer changes a composable (intentionally or otherwise).
|
||||||
2. CI runs `verifyRoborazziDebug` and fails on any drift.
|
2. CI runs `verifyRoborazziDebug` and fails on any drift; the
|
||||||
|
`roborazzi-diffs` artifact is uploaded for review.
|
||||||
3. Developer inspects the diff locally via `compareRoborazziDebug` or
|
3. Developer inspects the diff locally via `compareRoborazziDebug` or
|
||||||
from the CI artifact.
|
from the CI artifact.
|
||||||
4. If the drift is intentional, regenerate via
|
4. If the drift is intentional, regenerate via
|
||||||
`recordRoborazziDebug` and commit the new PNGs inside the PR so the
|
`recordRoborazziDebug` and commit the new PNGs inside the PR so the
|
||||||
reviewer explicitly sign-offs on each image change.
|
reviewer explicitly signs off on each image change.
|
||||||
5. If the drift is a regression, fix the composable and re-run.
|
5. If the drift is a regression, fix the composable and re-run.
|
||||||
|
|
||||||
**Reviewer checklist:** every committed `.png` under the roborazzi output
|
**Reviewer checklist:** every committed `.png` under the roborazzi output
|
||||||
@@ -94,6 +102,16 @@ Add the corresponding dark-mode and other-theme variants, then run
|
|||||||
- `captureRoboImage` only captures the composable tree, not window
|
- `captureRoboImage` only captures the composable tree, not window
|
||||||
chrome (status bar, navigation bar). That's intentional — chrome
|
chrome (status bar, navigation bar). That's intentional — chrome
|
||||||
is owned by the OS, not our design system.
|
is owned by the OS, not our design system.
|
||||||
|
- We use the standalone `captureRoboImage(filePath) { composable }`
|
||||||
|
helper from `roborazzi-compose` instead of the
|
||||||
|
`createComposeRule() + RoborazziRule` approach. The helper registers
|
||||||
|
`RoborazziTransparentActivity` with Robolectric's shadow PackageManager
|
||||||
|
on its own, avoiding the
|
||||||
|
"Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
|
||||||
|
failure you hit if `createComposeRule` tries to launch
|
||||||
|
`androidx.activity.ComponentActivity` through `ActivityScenario` on the
|
||||||
|
unit-test classpath (where the manifest declares `ComponentActivity`
|
||||||
|
without a MAIN/LAUNCHER intent filter).
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
|||||||