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