1 /*
2  * Copyright 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.test.screenshot
18 
19 import android.graphics.Bitmap
20 import android.graphics.BitmapFactory
21 import android.os.Build
22 import android.os.Bundle
23 import android.util.Log
24 import androidx.test.platform.app.InstrumentationRegistry
25 import androidx.test.screenshot.matchers.BitmapMatcher
26 import androidx.test.screenshot.matchers.MSSIMMatcher
27 import androidx.test.screenshot.matchers.PixelPerfectMatcher
28 import androidx.test.screenshot.proto.DiffResultProto
29 import androidx.test.screenshot.proto.DiffResultProto.DiffResult.Status
30 import java.io.File
31 import java.io.FileNotFoundException
32 import java.io.FileOutputStream
33 import java.io.IOException
34 import org.junit.Assume
35 import org.junit.rules.TestRule
36 import org.junit.rules.TestWatcher
37 import org.junit.runner.Description
38 import org.junit.runners.model.Statement
39 
40 /**
41  * Config for [ScreenshotTestRule].
42  *
43  * To be used to set up paths to golden images. These paths are not used to retrieve the goldens
44  * during the test. They are just directly stored into the result proto file. The proto file can
45  * then be used by CI to determined where to put the new approved goldens. Your tests assets
46  * directory should be pointing to exactly the same path.
47  *
48  * @param repoRootPathForGoldens Path to the repo's root that contains the goldens. To be used by
49  *   CI.
50  * @param pathToGoldensInRepo Relative path to goldens inside your [repoRootPathForGoldens].
51  */
52 class ScreenshotTestRuleConfig(
53     val repoRootPathForGoldens: String = "",
54     val pathToGoldensInRepo: String = ""
55 )
56 
57 /** Type of file that can be produced by the [ScreenshotTestRule]. */
58 internal enum class OutputFileType {
59     IMAGE_ACTUAL,
60     IMAGE_EXPECTED,
61     IMAGE_DIFF,
62     TEXT_RESULT_PROTO,
63     DIFF_TEXT_RESULT_PROTO,
64     RESULT_PROTO
65 }
66 
67 /**
68  * Rule to be added to a test to facilitate screenshot testing.
69  *
70  * This rule records current test name and when instructed it will perform the given bitmap
71  * comparison against the given golden. All the results (including result proto file) are stored
72  * into the device to be retrieved later.
73  *
74  * @param config To configure where this rule should look for goldens.
75  * @see Bitmap.assertAgainstGolden
76  */
77 open class ScreenshotTestRule(config: ScreenshotTestRuleConfig = ScreenshotTestRuleConfig()) :
78     TestRule {
79 
80     /** Directory on the device that is used to store the output files. */
81     val deviceOutputDirectory
82         get() =
83             File(
84                 InstrumentationRegistry.getInstrumentation().getContext().externalCacheDir,
85                 "androidx_screenshots"
86             )
87 
88     private val repoRootPathForGoldens = config.repoRootPathForGoldens.trim('/')
89     private val pathToGoldensInRepo = config.pathToGoldensInRepo.trim('/')
90     private val imageExtension = ".png"
91     // This is used in CI to identify the files.
92     private val resultTextProtoFileSuffix = "goldResult.textproto"
93     private val resultProtoFileSuffix = "goldResult.pb"
94 
95     // Magic number for an in-progress status report
96     private val bundleStatusInProgress = 2
97     private val bundleKeyPrefix = "androidx_screenshots_"
98 
99     private lateinit var testIdentifier: String
100     private lateinit var deviceId: String
101 
102     private var goldenIdentifierResolver: ((String) -> String) = ::resolveGoldenName
103 
104     private val testWatcher =
105         object : TestWatcher() {
startingnull106             override fun starting(description: Description?) {
107                 deviceId = getDeviceModel()
108                 testIdentifier = "${description!!.className}_${description.methodName}_$deviceId"
109             }
110         }
111 
applynull112     override fun apply(base: Statement, description: Description?): Statement {
113         return ScreenshotTestStatement(base).run { testWatcher.apply(this, description) }
114     }
115 
116     class ScreenshotTestStatement(private val base: Statement) : Statement() {
evaluatenull117         override fun evaluate() {
118             if (Build.MODEL.contains("gphone")) {
119                 // We support emulators with API 35
120                 Assume.assumeTrue("Requires SDK 35.", Build.VERSION.SDK_INT == 35)
121             } else {
122                 Assume.assumeTrue("Requires API 35 emulator", false)
123             }
124             base.evaluate()
125         }
126     }
127 
setCustomGoldenIdResolvernull128     internal fun setCustomGoldenIdResolver(resolver: ((String) -> String)) {
129         goldenIdentifierResolver = resolver
130     }
131 
clearCustomGoldenIdResolvernull132     internal fun clearCustomGoldenIdResolver() {
133         goldenIdentifierResolver = ::resolveGoldenName
134     }
135 
resolveGoldenNamenull136     private fun resolveGoldenName(goldenIdentifier: String): String {
137         return "${goldenIdentifier}_$deviceId$imageExtension"
138     }
139 
fetchExpectedImagenull140     private fun fetchExpectedImage(goldenIdentifier: String): Bitmap? {
141         val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
142 
143         try {
144             context.assets.open(goldenIdentifierResolver(goldenIdentifier)).use {
145                 return BitmapFactory.decodeStream(it)
146             }
147         } catch (e: FileNotFoundException) {
148             // Golden not present
149             return null
150         }
151     }
152 
153     /**
154      * Asserts the given bitmap against the golden identified by the given name.
155      *
156      * Note: The golden identifier should be unique per your test module (unless you want multiple
157      * tests to match the same golden). The name must not contain extension. You should also avoid
158      * adding strings like "golden", "image" and instead describe what is the golder referring to.
159      *
160      * @param actual The bitmap captured during the test.
161      * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
162      * @param matcher The algorithm to be used to perform the matching.
163      * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
164      *   empty.
165      * @see MSSIMMatcher
166      * @see PixelPerfectMatcher
167      * @see Bitmap.assertAgainstGolden
168      */
assertBitmapAgainstGoldennull169     fun assertBitmapAgainstGolden(
170         actual: Bitmap,
171         goldenIdentifier: String,
172         matcher: BitmapMatcher
173     ) {
174         if (!goldenIdentifier.matches("^[A-Za-z0-9_-]+$".toRegex())) {
175             throw IllegalArgumentException(
176                 "The given golden identifier '$goldenIdentifier' does not satisfy the naming " +
177                     "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
178             )
179         }
180 
181         val expected = fetchExpectedImage(goldenIdentifier)
182         if (expected == null) {
183             reportResult(
184                 status = Status.MISSING_REFERENCE,
185                 goldenIdentifier = goldenIdentifier,
186                 actual = actual
187             )
188             throw AssertionError(
189                 "Missing golden image " +
190                     "'${goldenIdentifierResolver(goldenIdentifier)}'. " +
191                     "Did you mean to check in a new image?"
192             )
193         }
194 
195         if (actual.width != expected.width || actual.height != expected.height) {
196             reportResult(
197                 status = Status.FAILED,
198                 goldenIdentifier = goldenIdentifier,
199                 actual = actual,
200                 expected = expected
201             )
202             throw AssertionError(
203                 "Sizes are different! Expected: [${expected.width}, ${expected
204                     .height}], Actual: [${actual.width}, ${actual.height}]"
205             )
206         }
207 
208         val comparisonResult =
209             matcher.compareBitmaps(
210                 expected = expected.toIntArray(),
211                 given = actual.toIntArray(),
212                 width = actual.width,
213                 height = actual.height
214             )
215 
216         val status =
217             if (comparisonResult.matches) {
218                 Status.PASSED
219             } else {
220                 Status.FAILED
221             }
222 
223         reportResult(
224             status = status,
225             goldenIdentifier = goldenIdentifier,
226             actual = actual,
227             expected = expected,
228             diff = comparisonResult.diff
229         )
230 
231         if (!comparisonResult.matches) {
232             throw AssertionError(
233                 "Image mismatch! Comparison stats: '${comparisonResult
234                     .comparisonStatistics}'"
235             )
236         }
237     }
238 
reportResultnull239     private fun reportResult(
240         status: Status,
241         goldenIdentifier: String,
242         actual: Bitmap,
243         expected: Bitmap? = null,
244         diff: Bitmap? = null
245     ) {
246         val diffResultProto = DiffResultProto.DiffResult.newBuilder().setResultType(status)
247 
248         diffResultProto.setImageLocationGolden(
249             if (pathToGoldensInRepo.isEmpty()) {
250                 goldenIdentifierResolver(goldenIdentifier)
251             } else {
252                 "$pathToGoldensInRepo/${goldenIdentifierResolver(goldenIdentifier)}"
253             }
254         )
255 
256         val metadata =
257             DiffResultProto.DiffResult.Metadata.newBuilder()
258                 .setKey("repoRootPath")
259                 .setValue(repoRootPathForGoldens)
260                 .build()
261 
262         diffResultProto.addMetadata(metadata)
263 
264         val report = Bundle()
265 
266         if (status != Status.PASSED) {
267             actual.writeToDevice(OutputFileType.IMAGE_ACTUAL, status).also {
268                 diffResultProto.imageLocationTest = it.name
269                 report.putString(bundleKeyPrefix + OutputFileType.IMAGE_ACTUAL, it.absolutePath)
270             }
271             diff?.run {
272                 writeToDevice(OutputFileType.IMAGE_DIFF, status).also {
273                     diffResultProto.imageLocationDiff = it.name
274                     report.putString(bundleKeyPrefix + OutputFileType.IMAGE_DIFF, it.absolutePath)
275                 }
276             }
277             expected?.run {
278                 writeToDevice(OutputFileType.IMAGE_EXPECTED, status).also {
279                     diffResultProto.imageLocationReference = it.name
280                     report.putString(
281                         bundleKeyPrefix + OutputFileType.IMAGE_EXPECTED,
282                         it.absolutePath
283                     )
284                 }
285             }
286         }
287 
288         writeToDevice(OutputFileType.DIFF_TEXT_RESULT_PROTO, status) {
289                 it.write(diffResultProto.build().toString().toByteArray())
290             }
291             .also {
292                 report.putString(
293                     bundleKeyPrefix + OutputFileType.DIFF_TEXT_RESULT_PROTO,
294                     it.absolutePath
295                 )
296             }
297 
298         writeToDevice(OutputFileType.RESULT_PROTO, status) {
299                 it.write(diffResultProto.build().toByteArray())
300             }
301             .also {
302                 report.putString(bundleKeyPrefix + OutputFileType.RESULT_PROTO, it.absolutePath)
303             }
304 
305         InstrumentationRegistry.getInstrumentation().sendStatus(bundleStatusInProgress, report)
306     }
307 
getPathOnDeviceFornull308     internal fun getPathOnDeviceFor(fileType: OutputFileType): File {
309         val fileName =
310             when (fileType) {
311                 OutputFileType.IMAGE_ACTUAL -> "${testIdentifier}_actual$imageExtension"
312                 OutputFileType.IMAGE_EXPECTED -> "${testIdentifier}_expected$imageExtension"
313                 OutputFileType.IMAGE_DIFF -> "${testIdentifier}_diff$imageExtension"
314                 OutputFileType.TEXT_RESULT_PROTO -> "${testIdentifier}_$resultTextProtoFileSuffix"
315                 OutputFileType.RESULT_PROTO -> "${testIdentifier}_diffResult_$resultProtoFileSuffix"
316                 OutputFileType.DIFF_TEXT_RESULT_PROTO ->
317                     "${testIdentifier}_diffResult_$resultTextProtoFileSuffix"
318             }
319         return File(deviceOutputDirectory, fileName)
320     }
321 
Bitmapnull322     private fun Bitmap.writeToDevice(fileType: OutputFileType, status: Status): File {
323         return writeToDevice(fileType, status) {
324             compress(Bitmap.CompressFormat.PNG, 0 /*ignored for png*/, it)
325         }
326     }
327 
writeToDevicenull328     private fun writeToDevice(
329         fileType: OutputFileType,
330         status: Status,
331         writeAction: (FileOutputStream) -> Unit
332     ): File {
333         if (!deviceOutputDirectory.exists() && !deviceOutputDirectory.mkdir()) {
334             throw IOException("Could not create folder.")
335         }
336 
337         val file = getPathOnDeviceFor(fileType)
338         if (status != Status.UNSPECIFIED && status != Status.PASSED) {
339             Log.d(javaClass.simpleName, "Writing screenshot test result $fileType to $file")
340         }
341         try {
342             FileOutputStream(file).use { writeAction(it) }
343         } catch (e: Exception) {
344             throw IOException(
345                 "Could not write file to storage (path: ${file.absolutePath}). " +
346                     " Stacktrace: " +
347                     e.stackTrace
348             )
349         }
350         return file
351     }
352 
getDeviceModelnull353     private fun getDeviceModel(): String {
354         var model = Build.MODEL.lowercase()
355         model = model.replace("sdk_gphone64_", "emulator")
356         arrayOf("phone", "x86_64", "x86", "x64", "gms", "arm64").forEach {
357             model = model.replace(it, "")
358         }
359         return model.trim().replace(" ", "_")
360     }
361 }
362 
toIntArraynull363 internal fun Bitmap.toIntArray(): IntArray {
364     val bitmapArray = IntArray(width * height)
365     getPixels(bitmapArray, 0, width, 0, 0, width, height)
366     return bitmapArray
367 }
368 
369 /**
370  * Asserts this bitmap against the golden identified by the given name.
371  *
372  * Note: The golden identifier should be unique per your test module (unless you want multiple tests
373  * to match the same golden). The name must not contain extension. You should also avoid adding
374  * strings like "golden", "image" and instead describe what is the golder referring to.
375  *
376  * @param rule The screenshot test rule that provides the comparison and reporting.
377  * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
378  * @param matcher The algorithm to be used to perform the matching. By default [MSSIMMatcher] is
379  *   used.
380  * @see MSSIMMatcher
381  * @see PixelPerfectMatcher
382  */
assertAgainstGoldennull383 fun Bitmap.assertAgainstGolden(
384     rule: ScreenshotTestRule,
385     goldenIdentifier: String,
386     matcher: BitmapMatcher = MSSIMMatcher()
387 ) {
388     rule.assertBitmapAgainstGolden(this, goldenIdentifier, matcher = matcher)
389 }
390