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