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>
This commit is contained in:
Trey T
2026-04-18 17:57:35 -05:00
parent 77f32befb8
commit 0c554cce6a
40 changed files with 56 additions and 36 deletions

View File

@@ -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:

View File

@@ -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"))
}

View File

@@ -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")
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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