Paparazzi wrapper library

Test: ./gradlew :test:screenshot:screenshot-paparazzi:test
Bug: 241128513
Change-Id: I43e4d0d1eef598c23f0278fcabdca9964689d793
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9f483aa..cb23220 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -170,6 +170,7 @@
 playServicesBasement = { module = "com.google.android.gms:play-services-basement", version = "17.0.0" }
 playServicesWearable = { module = "com.google.android.gms:play-services-wearable", version = "17.1.0" }
 paparazzi = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazzi" }
+paparazziNativeJvm = { module = "app.cash.paparazzi:layoutlib-native-jdk11", version.ref = "paparazziNative" }
 paparazziNativeLinuxX64 = { module = "app.cash.paparazzi:layoutlib-native-linux", version.ref = "paparazziNative" }
 paparazziNativeMacOsArm64 = { module = "app.cash.paparazzi:layoutlib-native-macarm", version.ref = "paparazziNative" }
 paparazziNativeMacOsX64 = { module = "app.cash.paparazzi:layoutlib-native-macosx", version.ref = "paparazziNative" }
diff --git a/settings.gradle b/settings.gradle
index 06f39d7..fd24c42 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -847,6 +847,7 @@
 includeProject(":test:ext:junit-gtest", [BuildType.NATIVE])
 includeProject(":test:integration-tests:junit-gtest-test", [BuildType.NATIVE])
 includeProject(":test:screenshot:screenshot")
+includeProject(":test:screenshot:screenshot-paparazzi")
 includeProject(":test:screenshot:screenshot-proto")
 includeProject(":test:uiautomator:uiautomator", [BuildType.MAIN])
 includeProject(":test:uiautomator:integration-tests:testapp", [BuildType.MAIN])
diff --git a/test/screenshot/screenshot-paparazzi/build.gradle b/test/screenshot/screenshot-paparazzi/build.gradle
new file mode 100644
index 0000000..d702cbb
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/build.gradle
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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.
+ */
+
+import androidx.build.BundleInsideHelper
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("kotlin")
+}
+
+BundleInsideHelper.forInsideJar(project, "com.google.protobuf", "androidx.test.screenshot.protobuf")
+
+dependencies {
+    api(libs.paparazzi)
+    bundleInside(project(path: ":test:screenshot:screenshot-proto", configuration: "export"))
+
+    constraints {
+        implementation(libs.paparazziNativeJvm) {
+            because("Paparazzi's JVM layoutlib artifact must exactly match the native layoutlib " +
+                    "artifact managed by AndroidXPaparazziPlugin")
+        }
+    }
+
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinTestJunit)
+}
+
+androidx {
+    name = "AndroidX Library Screenshot Test - Paparazzi Wrapper"
+    type = LibraryType.INTERNAL_TEST_LIBRARY
+    mavenGroup = LibraryGroups.TESTSCREENSHOT
+}
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/AndroidXPaparazziTestRule.kt b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/AndroidXPaparazziTestRule.kt
new file mode 100644
index 0000000..fbdc87d
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/AndroidXPaparazziTestRule.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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 androidx.test.screenshot.paparazzi
+
+import app.cash.paparazzi.DeviceConfig
+import app.cash.paparazzi.Environment
+import app.cash.paparazzi.Paparazzi
+import com.android.ide.common.rendering.api.SessionParams.RenderingMode
+import java.io.File
+
+/**
+ * Creates a [Paparazzi] test rule configured from system properties for AndroidX tests.
+ */
+fun AndroidXPaparazziTestRule(
+    deviceConfig: DeviceConfig = DeviceConfig.NEXUS_5.copy(softButtons = false),
+    theme: String = "android:Theme.Material.NoActionBar.Fullscreen",
+    renderingMode: RenderingMode = RenderingMode.NORMAL,
+    imageDiffer: ImageDiffer = ImageDiffer.PixelPerfect
+) = Paparazzi(
+    deviceConfig = deviceConfig,
+    theme = theme,
+    renderingMode = renderingMode,
+    environment = Environment(
+        platformDir = systemProperty("platformDir").toFile().path,
+        resDir = systemProperty("resDir").toFile().path,
+        assetsDir = systemProperty("assetsDir").toFile().path,
+        packageName = systemProperty("packageName"),
+        compileSdkVersion = systemProperty("compileSdkVersion").toInt(),
+        platformDataDir = systemProperty("platformDataDir").toFile().path,
+        resourcePackageNames = systemProperty("resourcePackageNames").split(","),
+        appTestDir = System.getProperty("user.dir")!!
+    ),
+    snapshotHandler = GoldenVerifier(
+        modulePath = systemProperty("modulePath"),
+        goldenRootDirectory = systemProperty("goldenRootDir").toFile(),
+        reportDirectory = systemProperty("reportDir").toFile(),
+        imageDiffer = imageDiffer
+    )
+)
+
+/** Package name used for resolving system properties */
+private const val PACKAGE_NAME = "androidx.test.screenshot.paparazzi"
+
+/** Read a system property with [PACKAGE_NAME] prefix, throwing an exception if missing */
+private fun systemProperty(name: String) =
+    requireNotNull(System.getProperty("$PACKAGE_NAME.$name")) {
+        "System property $PACKAGE_NAME.$name is not set. You may need to apply " +
+            "AndroidXPaparazziPlugin to your Gradle build."
+    }
+
+/** Little helper to convert string path to [File] to improve readability */
+@Suppress("NOTHING_TO_INLINE")
+private inline fun String.toFile() = File(this).canonicalFile
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifier.kt b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifier.kt
new file mode 100644
index 0000000..1e58c90
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifier.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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 androidx.test.screenshot.paparazzi
+
+import androidx.test.screenshot.proto.ScreenshotResultProto.ScreenshotResult
+import androidx.test.screenshot.proto.ScreenshotResultProto.ScreenshotResult.Status
+import app.cash.paparazzi.Snapshot
+import app.cash.paparazzi.SnapshotHandler
+import app.cash.paparazzi.TestName
+import java.awt.image.BufferedImage
+import java.io.File
+import javax.imageio.ImageIO
+
+/**
+ * This [SnapshotHandler] implements image diffing for AndroidX CI. It both throws exceptions for
+ * failing tests and writes reports out for the image diffing tool in CI to consume.
+ *
+ * All golden images are identified by the qualified name of the test function. This limits tests
+ * to one snapshot per test, but avoids introducing a secondary identifier. It's also currently
+ * required for CI.
+ *
+ * It always fails the test if the expected golden image does not exist yet, but provides an
+ * failure message including the path to the actual screenshot and the expected golden path.
+ *
+ * @property modulePath Unique path for the module, derived from gradle path. The verifier will
+ * search for golden images in this directory relative to [goldenRootDirectory].
+ * Example: `test/screenshot/paparazzi`.
+ *
+ * @property goldenRootDirectory Location on disk of the golden images repo. Golden images for this
+ * module are found in the [modulePath] directory under this directory.
+ *
+ * @property reportDirectory Directory to write reports for CI to read, including protos,
+ * actual and expected images, and image diffs.
+ *
+ * @property imageDiffer An [ImageDiffer] for comparing images.
+ *
+ * @property goldenRepoName Name of the repo containing golden images. Used for CI
+ */
+internal class GoldenVerifier(
+    val modulePath: String,
+    val goldenRootDirectory: File,
+    val reportDirectory: File,
+    val imageDiffer: ImageDiffer = ImageDiffer.PixelPerfect,
+    val goldenRepoName: String = ANDROIDX_GOLDEN_REPO_NAME
+) : SnapshotHandler {
+    /** Directory containing golden images for this module. */
+    val goldenDirectory = goldenRootDirectory.resolve(modulePath)
+
+    /**
+     * Asserts that the [actual] matches the expected golden for the qualified test function name,
+     * [testId]. As a side effect, this writes the report proto, actual, expected, and difference
+     * images to [reportDirectory] as appropriate.
+     */
+    fun assertSimilarToGolden(testId: String, actual: BufferedImage) {
+        val expected = testId.toGoldenFile().takeIf { it.canRead() }?.let { ImageIO.read(it) }
+        val analysis = analyze(expected, actual)
+
+        fun updateMessage() = "To update the golden image, copy " +
+            "${testId.toActualFile().canonicalPath} to ${testId.toGoldenFile().canonicalPath} " +
+            "and commit the updated golden image."
+
+        writeReport(testId, analysis)
+
+        when (analysis) {
+            is AnalysisResult.Passed -> { /** Test passed, don't need to throw anything */ }
+            is AnalysisResult.Failed -> throw AssertionError(
+                "Actual image differs from golden image: ${analysis.imageDiff.description}. " +
+                    updateMessage()
+            )
+            is AnalysisResult.SizeMismatch -> throw AssertionError(
+                "Actual image has different dimensions than golden image. " +
+                    "Actual: ${analysis.actual.width}x${analysis.actual.height}. " +
+                    "Golden: ${analysis.expected.width}x${analysis.expected.height}. " +
+                    updateMessage()
+            )
+            is AnalysisResult.MissingGolden -> throw AssertionError(
+                "Expected golden image for $testId does not exist. To create it, copy " +
+                    "${testId.toActualFile().canonicalPath} to " +
+                    "${testId.toGoldenFile().canonicalPath} and commit the new golden image."
+            )
+        }
+    }
+
+    /** Compare [expected] golden image to [actual] image and return an [AnalysisResult] */
+    fun analyze(expected: BufferedImage?, actual: BufferedImage): AnalysisResult {
+        if (expected == null) {
+            return AnalysisResult.MissingGolden(actual)
+        }
+
+        if (actual.width != expected.width || actual.height != expected.height) {
+            return AnalysisResult.SizeMismatch(actual, expected)
+        }
+
+        return when (val diff = imageDiffer.diff(actual, expected)) {
+            is ImageDiffer.DiffResult.Similar -> AnalysisResult.Passed(actual, expected, diff)
+            is ImageDiffer.DiffResult.Different -> AnalysisResult.Failed(actual, expected, diff)
+        }
+    }
+
+    /**
+     * Write the [analysis] for test [testId] to [reportDirectory] as both binary and text proto,
+     * including actual, expected, and difference image files as appropriate.
+     */
+    fun writeReport(testId: String, analysis: AnalysisResult) {
+        val actualFile = testId.toActualFile().also { ImageIO.write(analysis.actual, "PNG", it) }
+        val goldenFile = testId.toGoldenFile()
+
+        val resultProto = ScreenshotResult.newBuilder().apply {
+            currentScreenshotFileName = actualFile.name
+            repoRootPath = goldenRepoName
+            locationOfGoldenInRepo = goldenFile.relativeTo(goldenRootDirectory).path
+        }
+
+        fun diffFile(diff: BufferedImage) =
+            testId.toDiffFile().also { ImageIO.write(diff, "PNG", it) }
+        fun expectedFile() = goldenFile.copyTo(testId.toExpectedFile())
+
+        when (analysis) {
+            is AnalysisResult.Passed -> resultProto.apply {
+                result = Status.PASSED
+                expectedImageFileName = expectedFile().name
+                analysis.imageDiff.highlights?.let { diffImageFileName = diffFile(it).name }
+                comparisonStatistics = analysis.imageDiff.taggedDescription()
+            }
+            is AnalysisResult.Failed -> resultProto.apply {
+                result = Status.FAILED
+                expectedImageFileName = expectedFile().name
+                diffImageFileName = diffFile(analysis.imageDiff.highlights).name
+                comparisonStatistics = analysis.imageDiff.taggedDescription()
+            }
+            is AnalysisResult.SizeMismatch -> resultProto.apply {
+                result = Status.SIZE_MISMATCH
+                expectedImageFileName = expectedFile().name
+            }
+            is AnalysisResult.MissingGolden -> resultProto.apply {
+                result = Status.MISSING_GOLDEN
+            }
+        }
+
+        val result = resultProto.build()
+        testId.toResultProtoFile().outputStream().use { result.writeTo(it) }
+
+        // TODO(b/244200590): Remove text proto output, or replace with JSON
+        testId.toResultTextProtoFile().writeText(result.toString())
+    }
+
+    /**
+     * Analysis result ADT returned from [analyze], including actual and expected images and
+     * an [ImageDiffer.DiffResult].
+     */
+    sealed interface AnalysisResult {
+        val actual: BufferedImage
+
+        data class Passed(
+            override val actual: BufferedImage,
+            val expected: BufferedImage,
+            val imageDiff: ImageDiffer.DiffResult.Similar
+        ) : AnalysisResult
+
+        data class Failed(
+            override val actual: BufferedImage,
+            val expected: BufferedImage,
+            val imageDiff: ImageDiffer.DiffResult.Different
+        ) : AnalysisResult
+
+        data class SizeMismatch(
+            override val actual: BufferedImage,
+            val expected: BufferedImage
+        ) : AnalysisResult
+
+        data class MissingGolden(
+            override val actual: BufferedImage
+        ) : AnalysisResult
+    }
+
+    override fun newFrameHandler(
+        snapshot: Snapshot,
+        frameCount: Int,
+        fps: Int
+    ): SnapshotHandler.FrameHandler {
+        require(frameCount == 1) { "Videos are not yet supported" }
+
+        return object : SnapshotHandler.FrameHandler {
+            override fun handle(image: BufferedImage) {
+                assertSimilarToGolden(snapshot.testName.toTestId(), image)
+            }
+
+            override fun close() {}
+        }
+    }
+
+    override fun close() {}
+
+    /** Adds [ImageDiffer.name] as a prefix to [ImageDiffer.DiffResult.description]. */
+    private fun ImageDiffer.DiffResult.taggedDescription() =
+        "[${imageDiffer.name}]: $description"
+
+    // Filename templates based for a given test ID
+    private fun String.toGoldenFile() = goldenDirectory.resolve("${this}_paparazzi.png")
+    private fun String.toExpectedFile() = reportDirectory.resolve("${this}_expected.png")
+    private fun String.toActualFile() = reportDirectory.resolve("${this}_actual.png")
+    private fun String.toDiffFile() = reportDirectory.resolve("${this}_diff.png")
+    private fun String.toResultProtoFile() = reportDirectory.resolve("${this}_goldResult.pb")
+    private fun String.toResultTextProtoFile() =
+        reportDirectory.resolve("${this}_goldResult.textproto")
+
+    companion object {
+        /** Name of the AndroidX golden repo. */
+        const val ANDROIDX_GOLDEN_REPO_NAME = "platform/frameworks/support-golden"
+
+        /** Render test function name as a fully qualified string. */
+        fun TestName.toTestId(): String {
+            return if (packageName.isEmpty()) {
+                "${className}_$methodName"
+            } else {
+                "$packageName.${className}_$methodName"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/ImageDiffer.kt b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/ImageDiffer.kt
new file mode 100644
index 0000000..72e9e99
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/main/kotlin/androidx/test/screenshot/paparazzi/ImageDiffer.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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 androidx.test.screenshot.paparazzi
+
+import androidx.test.screenshot.paparazzi.ImageDiffer.DiffResult.Similar
+import java.awt.image.BufferedImage
+
+/**
+ *  Functional interface to compare two images and returns a [ImageDiffer.DiffResult] ADT containing
+ *  comparison statistics and a difference image, if applicable.
+ */
+fun interface ImageDiffer {
+    /**
+     * Compare image [a] to image [b]. Implementations may assume [a] and [b] have the same
+     * dimensions.
+     */
+    fun diff(a: BufferedImage, b: BufferedImage): DiffResult
+
+    /** A name to be used in logs for this differ, defaulting to the class's simple name. */
+    val name
+        get() = requireNotNull(this::class.simpleName) {
+            "Could not determine ImageDiffer.name reflectively. Please override ImageDiffer.name."
+        }
+
+    /**
+     * Result ADT returned from [diff].
+     *
+     * A differ may permit a small amount of difference, even for [Similar] results. Similar results
+     * must include a [description], even if it's trivial, but may omit the [highlights] image if
+     * it would be fully transparent.
+     *
+     * @property description A human-readable description of how the images differed, such as the
+     * count of different pixels or percentage changed. Displayed in test failure messages and in
+     * CI.
+     *
+     * @property highlights An image with a transparent background, highlighting where the compared
+     * images differ, typically in shades of magenta. Displayed in CI.
+     */
+    sealed interface DiffResult {
+        val description: String
+        val highlights: BufferedImage?
+
+        data class Similar(
+            override val description: String,
+            override val highlights: BufferedImage? = null
+        ) : DiffResult
+
+        data class Different(
+            override val description: String,
+            override val highlights: BufferedImage
+        ) : DiffResult
+    }
+
+    /**
+     * Pixel perfect image differ requiring images to be identical.
+     *
+     * The alpha channel is treated as pre-multiplied, meaning RGB channels may differ if the alpha
+     * channel is 0 (fully transparent).
+     */
+    // TODO(b/244752233): Support wide gamut images.
+    object PixelPerfect : ImageDiffer {
+        override fun diff(a: BufferedImage, b: BufferedImage): DiffResult {
+            check(a.width == b.width && a.height == b.height) { "Images are different sizes" }
+            val width = a.width
+            val height = b.height
+            val highlights = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
+            var count = 0
+
+            for (x in 0 until width) {
+                for (y in 0 until height) {
+                    val aPixel = a.getRGB(x, y)
+                    val bPixel = b.getRGB(x, y)
+
+                    // Compare full ARGB pixels, but allow other channels to differ if alpha is 0
+                    if (aPixel == bPixel || (aPixel ushr 24 == 0 && bPixel ushr 24 == 0)) {
+                        highlights.setRGB(x, y, TRANSPARENT.toInt())
+                    } else {
+                        count++
+                        highlights.setRGB(x, y, MAGENTA.toInt())
+                    }
+                }
+            }
+
+            val description = "$count of ${width * height} pixels different"
+            return if (count > 0) {
+                DiffResult.Different(description, highlights)
+            } else {
+                DiffResult.Similar(description)
+            }
+        }
+    }
+
+    private companion object {
+        const val MAGENTA = 0xFF_FF_00_FFu
+        const val TRANSPARENT = 0x00_FF_FF_FFu
+    }
+}
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifierTest.kt b/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifierTest.kt
new file mode 100644
index 0000000..cbaa845
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/GoldenVerifierTest.kt
@@ -0,0 +1,303 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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 androidx.test.screenshot.paparazzi
+
+import androidx.test.screenshot.proto.ScreenshotResultProto.ScreenshotResult
+import androidx.test.screenshot.proto.ScreenshotResultProto.ScreenshotResult.Status
+import app.cash.paparazzi.Snapshot
+import java.awt.image.BufferedImage
+import java.io.File
+import java.util.Date
+import javax.imageio.ImageIO
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertFails
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import org.junit.rules.TestName
+
+class GoldenVerifierTest {
+    @get:Rule
+    val testName = TestName()
+
+    @get:Rule
+    val goldenDirectory = TemporaryFolder()
+
+    @get:Rule
+    val reportDirectory = TemporaryFolder()
+
+    private val modulePath = "test/screenshot/screenshot-paparazzi"
+
+    @Test
+    fun `snapshot handler success`() {
+        createGolden("circle")
+        goldenVerifier().newFrameHandler(snapshot(), 1, 0).handle(loadTestImage("circle"))
+    }
+
+    @Test
+    fun `snapshot handler failure`() {
+        createGolden("star")
+        assertFails {
+            goldenVerifier().newFrameHandler(snapshot(), 1, 0).handle(loadTestImage("circle"))
+        }
+    }
+
+    @Test
+    fun `writes report on success`() {
+        createGolden("circle")
+        goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle"))
+
+        val proto = reportProto()
+        assertEquals(Status.PASSED, proto.result)
+        assertEquals(reportFile("expected.png").name, proto.expectedImageFileName)
+        assertEquals("", proto.diffImageFileName)
+        assertEquals("[PixelPerfect]: 0 of 65536 pixels different", proto.comparisonStatistics)
+        assertContains(reportFile("goldResult.textproto").readText(), "PASSED")
+    }
+
+    @Test
+    fun `writes actual image on success`() {
+        createGolden("circle")
+        goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle"))
+        assertEquals(loadTestImage("circle"), reportFile("actual.png").readImage())
+    }
+
+    @Test
+    fun `writes expected image on success`() {
+        createGolden("circle")
+        goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle"))
+        assertEquals(loadTestImage("circle"), reportFile("expected.png").readImage())
+    }
+
+    @Test
+    fun `analysis of success`() {
+        val analysis = goldenVerifier().analyze(loadTestImage("circle"), loadTestImage("circle"))
+        assertIs<GoldenVerifier.AnalysisResult.Passed>(analysis)
+        assertEquals(loadTestImage("circle"), analysis.actual)
+        assertEquals(loadTestImage("circle"), analysis.expected)
+    }
+
+    @Test
+    fun `asserts on failure`() {
+        createGolden("star")
+        val message = "Actual image differs from golden image: 17837 of 65536 pixels different. " +
+            "To update the golden image, copy ${reportFile("actual.png")} to ${goldenFile()} and " +
+            "commit the updated golden image."
+
+        assertFailsWithMessage(message) {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle"))
+        }
+    }
+
+    @Test
+    fun `writes result proto on failure`() {
+        createGolden("star")
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+
+        val proto = reportProto()
+        assertEquals(Status.FAILED, proto.result)
+        assertEquals(reportFile("expected.png").name, proto.expectedImageFileName)
+        assertEquals(reportFile("diff.png").name, proto.diffImageFileName)
+        assertEquals("[PixelPerfect]: 17837 of 65536 pixels different", proto.comparisonStatistics)
+        assertContains(reportFile("goldResult.textproto").readText(), "FAILED")
+    }
+
+    @Test
+    fun `writes actual image on failure`() {
+        createGolden("star")
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+        assertEquals(loadTestImage("circle"), reportFile("actual.png").readImage())
+    }
+
+    @Test
+    fun `writes expected image on failure`() {
+        createGolden("star")
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+        assertEquals(loadTestImage("star"), reportFile("expected.png").readImage())
+    }
+
+    @Test
+    fun `writes diff image on failure`() {
+        createGolden("star")
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+        assertEquals(loadTestImage("PixelPerfect_diff"), reportFile("diff.png").readImage())
+    }
+
+    @Test
+    fun `analysis of failure`() {
+        val analysis = goldenVerifier().analyze(loadTestImage("circle"), loadTestImage("star"))
+        assertIs<GoldenVerifier.AnalysisResult.Failed>(analysis)
+        assertEquals(loadTestImage("star"), analysis.actual)
+        assertEquals(loadTestImage("circle"), analysis.expected)
+        assertEquals(loadTestImage("PixelPerfect_diff"), analysis.imageDiff.highlights)
+    }
+
+    @Test
+    fun `asserts on size mismatch`() {
+        createGolden("horizontal_rectangle")
+        val message = "Actual image has different dimensions than golden image. Actual: 72x128. " +
+            "Golden: 128x72. To update the golden image, copy ${reportFile("actual.png")} to " +
+            "${goldenFile()} and commit the updated golden image."
+
+        assertFailsWithMessage(message) {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("vertical_rectangle"))
+        }
+    }
+
+    @Test
+    fun `writes result proto for size mismatch`() {
+        createGolden("horizontal_rectangle")
+        assertFails {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("vertical_rectangle"))
+        }
+
+        val proto = reportProto()
+        assertEquals(Status.SIZE_MISMATCH, proto.result)
+        assertEquals(reportFile("expected.png").name, proto.expectedImageFileName)
+        assertEquals("", proto.diffImageFileName)
+        assertEquals("", proto.comparisonStatistics)
+        assertContains(reportFile("goldResult.textproto").readText(), "SIZE_MISMATCH")
+    }
+
+    @Test
+    fun `writes actual image for size mismatch`() {
+        createGolden("horizontal_rectangle")
+        assertFails {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("vertical_rectangle"))
+        }
+
+        assertEquals(loadTestImage("vertical_rectangle"), reportFile("actual.png").readImage())
+    }
+
+    @Test
+    fun `writes expected image for size mismatch`() {
+        createGolden("horizontal_rectangle")
+        assertFails {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("vertical_rectangle"))
+        }
+
+        assertEquals(loadTestImage("horizontal_rectangle"), reportFile("expected.png").readImage())
+    }
+
+    @Test
+    fun `analysis of size mismatch`() {
+        val analysis = goldenVerifier()
+            .analyze(loadTestImage("horizontal_rectangle"), loadTestImage("vertical_rectangle"))
+        assertIs<GoldenVerifier.AnalysisResult.SizeMismatch>(analysis)
+        assertEquals(loadTestImage("vertical_rectangle"), analysis.actual)
+        assertEquals(loadTestImage("horizontal_rectangle"), analysis.expected)
+    }
+
+    @Test
+    fun `asserts on missing golden`() {
+        val message = "Expected golden image for ${testId()} does not exist. To create it, copy " +
+            "${reportFile("actual.png")} to ${goldenFile()} and commit the new golden image."
+
+        assertFailsWithMessage(message) {
+            goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle"))
+        }
+    }
+
+    @Test
+    fun `writes result proto for missing golden`() {
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+
+        val proto = reportProto()
+        assertEquals(Status.MISSING_GOLDEN, proto.result)
+        assertEquals("", proto.expectedImageFileName)
+        assertEquals("", proto.diffImageFileName)
+        assertEquals("", proto.comparisonStatistics)
+        assertContains(reportFile("goldResult.textproto").readText(), "MISSING_GOLDEN")
+    }
+
+    @Test
+    fun `writes actual image for missing golden`() {
+        assertFails { goldenVerifier().assertSimilarToGolden(testId(), loadTestImage("circle")) }
+        assertEquals(loadTestImage("circle"), reportFile("actual.png").readImage())
+    }
+
+    @Test
+    fun `analysis of missing golden`() {
+        val analysis = goldenVerifier().analyze(null, loadTestImage("circle"))
+        assertIs<GoldenVerifier.AnalysisResult.MissingGolden>(analysis)
+        assertEquals(loadTestImage("circle"), analysis.actual)
+    }
+
+    private fun goldenVerifier() = GoldenVerifier(
+        modulePath = modulePath,
+        goldenRootDirectory = goldenDirectory.root,
+        reportDirectory = reportDirectory.root
+    )
+
+    /** Assert [block] throws an [AssertionError] with supplied [message]. */
+    private inline fun assertFailsWithMessage(message: String, block: () -> Unit) {
+        assertEquals(message, assertFailsWith<AssertionError> { block() }.message)
+    }
+
+    /** Compare two images using [ImageDiffer.PixelPerfect]. */
+    private fun assertEquals(expected: BufferedImage, actual: BufferedImage) {
+        assertIs<ImageDiffer.DiffResult.Similar>(
+            ImageDiffer.PixelPerfect.diff(expected, actual),
+            message = "Expected images to be identical, but they were not."
+        )
+    }
+
+    private fun snapshot() = Snapshot(
+        name = null,
+        testName = app.cash.paparazzi.TestName(
+            packageName = "androidx.test.screenshot.paparazzi",
+            className = this::class.simpleName!!,
+            methodName = testName.methodName
+        ),
+        timestamp = Date()
+    )
+
+    /** Create a golden image for this test from the supplied test image [name]. */
+    private fun createGolden(name: String) = javaClass.getResourceAsStream("$name.png")!!
+            .copyTo(goldenFile().apply { parentFile!!.mkdirs() }.outputStream())
+
+    /** Relative path to golden image for this test. */
+    private fun goldenPath() = "$modulePath/${testId()}_paparazzi.png"
+
+    /** Resolve the file path for a golden image for this test under [goldenDirectory]. */
+    private fun goldenFile() = goldenDirectory.root.resolve(goldenPath()).canonicalFile
+
+    /** Read the binary result proto under for this test and check common fields. */
+    private fun reportProto() =
+        ScreenshotResult.parseFrom(reportFile("goldResult.pb").inputStream()).also { proto ->
+            assertEquals(reportFile("actual.png").name, proto.currentScreenshotFileName)
+            assertEquals(GoldenVerifier.ANDROIDX_GOLDEN_REPO_NAME, proto.repoRootPath)
+            assertEquals(goldenPath(), proto.locationOfGoldenInRepo)
+        }
+
+    /** Resolve the file path for a report file with provided [suffix] under [reportDirectory]. */
+    private fun reportFile(suffix: String) =
+        reportDirectory.root.resolve("${testId()}_$suffix").canonicalFile
+
+    /** Convenience function to read an image from a file. */
+    private fun File.readImage() = ImageIO.read(this)
+
+    /** Fully qualified test ID for this test. */
+    private fun testId() = "${this::class.qualifiedName!!}_${testName.methodName}"
+
+    /** Load a test image from resources. */
+    private fun loadTestImage(name: String) =
+        ImageIO.read(javaClass.getResourceAsStream("$name.png")!!)
+}
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/ImageDifferTest.kt b/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/ImageDifferTest.kt
new file mode 100644
index 0000000..341d454
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/kotlin/androidx/test/screenshot/paparazzi/ImageDifferTest.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * 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 androidx.test.screenshot.paparazzi
+
+import androidx.test.screenshot.paparazzi.ImageDiffer.DiffResult.Different
+import androidx.test.screenshot.paparazzi.ImageDiffer.DiffResult.Similar
+import androidx.test.screenshot.paparazzi.ImageDiffer.PixelPerfect
+import javax.imageio.ImageIO
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+class ImageDifferTest {
+    @Test
+    fun `PixelPerfect similar`() {
+        val result = PixelPerfect.diff(loadTestImage("circle"), loadTestImage("circle"))
+        assertIs<Similar>(result)
+        assertEquals("0 of 65536 pixels different", result.description)
+        assertNull(result.highlights)
+    }
+
+    @Test
+    fun `PixelPerfect different`() {
+        val result = PixelPerfect.diff(loadTestImage("circle"), loadTestImage("star"))
+        assertIs<Different>(result)
+        assertEquals("17837 of 65536 pixels different", result.description)
+        assertIs<Similar>(
+            PixelPerfect.diff(result.highlights, loadTestImage("PixelPerfect_diff"))
+        )
+    }
+
+    @Test
+    fun `PixelPerfect name`() {
+        assertEquals("PixelPerfect", PixelPerfect.name)
+    }
+
+    private fun loadTestImage(name: String) =
+        ImageIO.read(javaClass.getResourceAsStream("$name.png")!!)
+}
\ No newline at end of file
diff --git a/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/PixelPerfect_diff.png b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/PixelPerfect_diff.png
new file mode 100644
index 0000000..8e3b7b8
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/PixelPerfect_diff.png
Binary files differ
diff --git a/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/circle.png b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/circle.png
new file mode 100644
index 0000000..e6d58321
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/circle.png
Binary files differ
diff --git a/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/horizontal_rectangle.png b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/horizontal_rectangle.png
new file mode 100644
index 0000000..a13221a
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/horizontal_rectangle.png
Binary files differ
diff --git a/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/star.png b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/star.png
new file mode 100644
index 0000000..61b0c64
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/star.png
Binary files differ
diff --git a/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/vertical_rectangle.png b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/vertical_rectangle.png
new file mode 100644
index 0000000..91a607e
--- /dev/null
+++ b/test/screenshot/screenshot-paparazzi/src/test/resources/androidx/test/screenshot/paparazzi/vertical_rectangle.png
Binary files differ