1 /*
<lambda>null2 * Copyright 2022 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 platform.test.screenshot
18
19 import android.annotation.ColorInt
20 import android.annotation.SuppressLint
21 import android.graphics.Bitmap
22 import android.graphics.BitmapFactory
23 import android.graphics.Color
24 import android.graphics.Rect
25 import android.platform.uiautomator_helpers.DeviceHelpers.shell
26 import android.provider.Settings.System
27 import androidx.annotation.VisibleForTesting
28 import androidx.test.platform.app.InstrumentationRegistry
29 import androidx.test.runner.screenshot.Screenshot
30 import com.android.internal.app.SimpleIconFactory
31 import java.io.FileNotFoundException
32 import org.junit.rules.TestRule
33 import org.junit.runner.Description
34 import org.junit.runners.model.Statement
35 import platform.test.screenshot.matchers.BitmapMatcher
36 import platform.test.screenshot.matchers.MSSIMMatcher
37 import platform.test.screenshot.matchers.MatchResult
38 import platform.test.screenshot.matchers.PixelPerfectMatcher
39 import platform.test.screenshot.parity.ParityStatsCollector
40 import platform.test.screenshot.proto.ScreenshotResultProto
41 import platform.test.screenshot.report.DiffResultExportStrategy
42
43 /**
44 * Rule to be added to a test to facilitate screenshot testing.
45 *
46 * This rule records current test name and when instructed it will perform the given bitmap
47 * comparison against the given golden. All the results (including result proto file) are stored
48 * into the device to be retrieved later.
49 *
50 * @see Bitmap.assertAgainstGolden
51 */
52 @SuppressLint("SyntheticAccessor")
53 open class ScreenshotTestRule
54 @VisibleForTesting
55 internal constructor(
56 val goldenPathManager: GoldenPathManager,
57 /** Strategy to report diffs to external systems. */
58 private val diffEscrowStrategy: DiffResultExportStrategy,
59 private val disableIconPool: Boolean = true
60 ) : TestRule, BitmapDiffer, ScreenshotAsserterFactory {
61
62 @JvmOverloads
63 constructor(
64 goldenPathManager: GoldenPathManager,
65 disableIconPool: Boolean = true,
66 ) : this(
67 goldenPathManager,
68 DiffResultExportStrategy.createDefaultStrategy(goldenPathManager),
69 disableIconPool
70 )
71
72 private val doesCollectScreenshotParityStats =
73 "yes".equals(
74 java.lang.System.getProperty("screenshot.collectScreenshotParityStats"),
75 ignoreCase = true)
76 private lateinit var testIdentifier: String
77
78 companion object {
79 private val parityStatsCollector = ParityStatsCollector()
80 }
81
82 override fun apply(base: Statement, description: Description): Statement =
83 object : Statement() {
84 override fun evaluate() {
85 try {
86 testIdentifier = getTestIdentifier(description)
87 if (disableIconPool) {
88 // Disables pooling of SimpleIconFactory objects as it caches
89 // density, which when updating the screen configuration in runtime
90 // sometimes it does not get updated in the icon renderer.
91 SimpleIconFactory.setPoolEnabled(false)
92 }
93 base.evaluate()
94 } finally {
95 if (disableIconPool) {
96 SimpleIconFactory.setPoolEnabled(true)
97 }
98 }
99 }
100 }
101
102 open fun getTestIdentifier(description: Description): String =
103 "${description.className}_${description.methodName}"
104
105 private fun fetchExpectedImage(goldenIdentifier: String): Bitmap? {
106 val instrument = InstrumentationRegistry.getInstrumentation()
107 return listOf(instrument.targetContext.applicationContext, instrument.context)
108 .map { context ->
109 try {
110 context.assets
111 .open(goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier))
112 .use {
113 return@use BitmapFactory.decodeStream(it)
114 }
115 } catch (e: FileNotFoundException) {
116 return@map null
117 }
118 }
119 .filterNotNull()
120 .firstOrNull()
121 }
122
123 /**
124 * Asserts the given bitmap against the golden identified by the given name.
125 *
126 * Note: The golden identifier should be unique per your test module (unless you want multiple
127 * tests to match the same golden). The name must not contain extension. You should also avoid
128 * adding strings like "golden", "image" and instead describe what is the golder referring to.
129 *
130 * @param actual The bitmap captured during the test.
131 * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
132 * @param matcher The algorithm to be used to perform the matching.
133 * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
134 * empty.
135 * @see MSSIMMatcher
136 * @see PixelPerfectMatcher
137 * @see Bitmap.assertAgainstGolden
138 */
139 @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
140 fun assertBitmapAgainstGolden(
141 actual: Bitmap,
142 goldenIdentifier: String,
143 matcher: BitmapMatcher
144 ) {
145 try {
146 assertBitmapAgainstGolden(
147 actual = actual,
148 goldenIdentifier = goldenIdentifier,
149 matcher = matcher,
150 regions = emptyList<Rect>()
151 )
152 } finally {
153 actual.recycle()
154 }
155 }
156
157 /**
158 * Asserts the given bitmap against the golden identified by the given name.
159 *
160 * Note: The golden identifier should be unique per your test module (unless you want multiple
161 * tests to match the same golden). The name must not contain extension. You should also avoid
162 * adding strings like "golden", "image" and instead describe what is the golder referring to.
163 *
164 * @param actual The bitmap captured during the test.
165 * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
166 * @param matcher The algorithm to be used to perform the matching.
167 * @param regions An optional array of interesting regions for partial screenshot diff.
168 * @throws IllegalArgumentException If the golden identifier contains forbidden characters or is
169 * empty.
170 * @see MSSIMMatcher
171 * @see PixelPerfectMatcher
172 * @see Bitmap.assertAgainstGolden
173 */
174 @Deprecated("use BitmapDiffer or ScreenshotAsserterFactory interfaces")
175 override fun assertBitmapAgainstGolden(
176 actual: Bitmap,
177 goldenIdentifier: String,
178 matcher: BitmapMatcher,
179 regions: List<Rect>
180 ) {
181 if (!goldenIdentifier.matches("^[A-Za-z0-9_-]+$".toRegex())) {
182 throw IllegalArgumentException(
183 "The given golden identifier '$goldenIdentifier' does not satisfy the naming " +
184 "requirement. Allowed characters are: '[A-Za-z0-9_-]'"
185 )
186 }
187
188 val expected = fetchExpectedImage(goldenIdentifier)
189 if (expected == null) {
190 diffEscrowStrategy.reportResult(
191 testIdentifier = testIdentifier,
192 goldenIdentifier = goldenIdentifier,
193 status = ScreenshotResultProto.DiffResult.Status.MISSING_REFERENCE,
194 actual = actual
195 )
196 throw AssertionError(
197 "Missing golden image " +
198 "'${goldenPathManager.goldenImageIdentifierResolver(goldenIdentifier)}'. " +
199 "Did you mean to check in a new image?"
200 )
201 }
202
203 if (expected.sameAs(actual)) {
204 if (doesCollectScreenshotParityStats) {
205 val stats =
206 ScreenshotResultProto.DiffResult.ComparisonStatistics.newBuilder()
207 .setNumberPixelsCompared(actual.width * actual.height)
208 .setNumberPixelsIdentical(actual.width * actual.height)
209 .setNumberPixelsDifferent(0)
210 .setNumberPixelsIgnored(0)
211 .build()
212 parityStatsCollector.collectTestStats(
213 testIdentifier,
214 MatchResult(matches = true, diff = null, comparisonStatistics = stats))
215 parityStatsCollector.report()
216 }
217 expected.recycle()
218 return
219 }
220
221 if (actual.width != expected.width || actual.height != expected.height) {
222 val comparisonResult =
223 matcher.compareBitmaps(
224 expected = expected.toIntArray(),
225 given = actual.toIntArray(),
226 expectedWidth = expected.width,
227 expectedHeight = expected.height,
228 actualWidth = actual.width,
229 actualHeight = actual.height
230 )
231 diffEscrowStrategy.reportResult(
232 testIdentifier = testIdentifier,
233 goldenIdentifier = goldenIdentifier,
234 status = ScreenshotResultProto.DiffResult.Status.FAILED,
235 actual = actual,
236 comparisonStatistics = comparisonResult.comparisonStatistics,
237 expected = expected,
238 diff = comparisonResult.diff
239 )
240 if (doesCollectScreenshotParityStats) {
241 parityStatsCollector.collectTestStats(testIdentifier, comparisonResult)
242 parityStatsCollector.report()
243 }
244
245 val expectedWidth = expected.width
246 val expectedHeight = expected.height
247 expected.recycle()
248
249 throw AssertionError(
250 "Sizes are different! Expected: [$expectedWidth, $expectedHeight], Actual: [${
251 actual.width}, ${actual.height}]. " +
252 "Force aligned at (0, 0). Comparison stats: '${comparisonResult
253 .comparisonStatistics}'"
254 )
255 }
256
257 val comparisonResult =
258 matcher.compareBitmaps(
259 expected = expected.toIntArray(),
260 given = actual.toIntArray(),
261 width = actual.width,
262 height = actual.height,
263 regions = regions
264 )
265 if (doesCollectScreenshotParityStats) {
266 parityStatsCollector.collectTestStats(testIdentifier, comparisonResult)
267 parityStatsCollector.report()
268 }
269
270 val status =
271 if (comparisonResult.matches) {
272 ScreenshotResultProto.DiffResult.Status.PASSED
273 } else {
274 ScreenshotResultProto.DiffResult.Status.FAILED
275 }
276
277 if (!comparisonResult.matches) {
278 val expectedWithHighlight = highlightedBitmap(expected, regions)
279 diffEscrowStrategy.reportResult(
280 testIdentifier = testIdentifier,
281 goldenIdentifier = goldenIdentifier,
282 status = status,
283 actual = actual,
284 comparisonStatistics = comparisonResult.comparisonStatistics,
285 expected = expectedWithHighlight,
286 diff = comparisonResult.diff
287 )
288
289 expectedWithHighlight.recycle()
290 expected.recycle()
291
292 throw AssertionError(
293 "Image mismatch! Comparison stats: '${comparisonResult.comparisonStatistics}'"
294 )
295 }
296
297 expected.recycle()
298 }
299
300 override fun createScreenshotAsserter(config: ScreenshotAsserterConfig): ScreenshotAsserter {
301 return ScreenshotRuleAsserter.Builder(this)
302 .withMatcher(config.matcher)
303 .setOnBeforeScreenshot(config.beforeScreenshot)
304 .setOnAfterScreenshot(config.afterScreenshot)
305 .setScreenshotProvider(config.captureStrategy)
306 .build()
307 }
308
309 /** This will create a new Bitmap with the output (not modifying the [original] Bitmap */
310 private fun highlightedBitmap(original: Bitmap, regions: List<Rect>): Bitmap {
311 if (regions.isEmpty()) return original
312
313 val outputBitmap = original.copy(original.config!!, true)
314 val imageRect = Rect(0, 0, original.width, original.height)
315 val regionLineWidth = 2
316 for (region in regions) {
317 val regionToDraw =
318 Rect(region).apply {
319 inset(-regionLineWidth, -regionLineWidth)
320 intersect(imageRect)
321 }
322
323 repeat(regionLineWidth) {
324 drawRectOnBitmap(outputBitmap, regionToDraw, Color.RED)
325 regionToDraw.inset(1, 1)
326 regionToDraw.intersect(imageRect)
327 }
328 }
329 return outputBitmap
330 }
331
332 private fun drawRectOnBitmap(bitmap: Bitmap, rect: Rect, @ColorInt color: Int) {
333 // Draw top and bottom edges
334 for (x in rect.left until rect.right) {
335 bitmap.setPixel(x, rect.top, color)
336 bitmap.setPixel(x, rect.bottom - 1, color)
337 }
338 // Draw left and right edge
339 for (y in rect.top until rect.bottom) {
340 bitmap.setPixel(rect.left, y, color)
341 bitmap.setPixel(rect.right - 1, y, color)
342 }
343 }
344 }
345
346 typealias BitmapSupplier = () -> Bitmap
347
348 /** Implements a screenshot asserter based on the ScreenshotRule */
349 class ScreenshotRuleAsserter private constructor(private val rule: ScreenshotTestRule) :
350 ScreenshotAsserter {
351 // use the most constraining matcher as default
352 private var matcher: BitmapMatcher = PixelPerfectMatcher()
353 private var beforeScreenshot: Runnable? = null
354 private var afterScreenshot: Runnable? = null
355
356 // use the instrumentation screenshot as default
<lambda>null357 private var screenShotter: BitmapSupplier = { Screenshot.capture().bitmap }
358
359 private var pointerLocationSetting: Int
360 get() = shell("settings get system ${System.POINTER_LOCATION}").trim().toIntOrNull() ?: 0
361 set(value) {
362 shell("settings put system ${System.POINTER_LOCATION} $value")
363 }
364
365 private var showTouchesSetting
366 get() = shell("settings get system ${System.SHOW_TOUCHES}").trim().toIntOrNull() ?: 0
367 set(value) {
368 shell("settings put system ${System.SHOW_TOUCHES} $value")
369 }
370
371 private var prevPointerLocationSetting: Int? = null
372 private var prevShowTouchesSetting: Int? = null
373 @Suppress("DEPRECATION")
assertGoldenImagenull374 override fun assertGoldenImage(goldenId: String) {
375 runBeforeScreenshot()
376 var actual: Bitmap? = null
377 try {
378 actual = screenShotter()
379 rule.assertBitmapAgainstGolden(actual, goldenId, matcher)
380 } finally {
381 actual?.recycle()
382 runAfterScreenshot()
383 }
384 }
385
386 @Suppress("DEPRECATION")
assertGoldenImagenull387 override fun assertGoldenImage(goldenId: String, areas: List<Rect>) {
388 runBeforeScreenshot()
389 var actual: Bitmap? = null
390 try {
391 actual = screenShotter()
392 rule.assertBitmapAgainstGolden(actual, goldenId, matcher, areas)
393 } finally {
394 actual?.recycle()
395 runAfterScreenshot()
396 }
397 }
398
runBeforeScreenshotnull399 private fun runBeforeScreenshot() {
400 prevPointerLocationSetting = pointerLocationSetting
401 prevShowTouchesSetting = showTouchesSetting
402
403 if (prevPointerLocationSetting != 0) pointerLocationSetting = 0
404 if (prevShowTouchesSetting != 0) showTouchesSetting = 0
405
406 beforeScreenshot?.run()
407 }
408
runAfterScreenshotnull409 private fun runAfterScreenshot() {
410 afterScreenshot?.run()
411
412 prevPointerLocationSetting?.let { pointerLocationSetting = it }
413 prevShowTouchesSetting?.let { showTouchesSetting = it }
414 }
415
416 @Deprecated("Use ScreenshotAsserterFactory instead")
417 class Builder(private val rule: ScreenshotTestRule) {
418 private var asserter = ScreenshotRuleAsserter(rule)
<lambda>null419 fun withMatcher(matcher: BitmapMatcher): Builder = apply { asserter.matcher = matcher }
420
421 /**
422 * The [Bitmap] produced by [screenshotProvider] will be recycled immediately after
423 * assertions are completed. Therefore, do not retain references to created [Bitmap]s.
424 */
<lambda>null425 fun setScreenshotProvider(screenshotProvider: BitmapSupplier): Builder = apply {
426 asserter.screenShotter = screenshotProvider
427 }
428
setOnBeforeScreenshotnull429 fun setOnBeforeScreenshot(run: Runnable): Builder = apply {
430 asserter.beforeScreenshot = run
431 }
432
<lambda>null433 fun setOnAfterScreenshot(run: Runnable): Builder = apply { asserter.afterScreenshot = run }
434
<lambda>null435 fun build(): ScreenshotAsserter = asserter.also { asserter = ScreenshotRuleAsserter(rule) }
436 }
437 }
438
toIntArraynull439 internal fun Bitmap.toIntArray(): IntArray {
440 val bitmapArray = IntArray(width * height)
441 getPixels(bitmapArray, 0, width, 0, 0, width, height)
442 return bitmapArray
443 }
444
445 /**
446 * Asserts this bitmap against the golden identified by the given name.
447 *
448 * Note: The golden identifier should be unique per your test module (unless you want multiple tests
449 * to match the same golden). The name must not contain extension. You should also avoid adding
450 * strings like "golden", "image" and instead describe what is the golder referring to.
451 *
452 * @param bitmapDiffer The screenshot test rule that provides the comparison and reporting.
453 * @param goldenIdentifier Name of the golden. Allowed characters: 'A-Za-z0-9_-'
454 * @param matcher The algorithm to be used to perform the matching. By default [MSSIMMatcher] is
455 * used.
456 * @see MSSIMMatcher
457 * @see PixelPerfectMatcher
458 */
assertAgainstGoldennull459 fun Bitmap.assertAgainstGolden(
460 bitmapDiffer: BitmapDiffer,
461 goldenIdentifier: String,
462 matcher: BitmapMatcher = MSSIMMatcher(),
463 regions: List<Rect> = emptyList()
464 ) {
465 bitmapDiffer.assertBitmapAgainstGolden(
466 this,
467 goldenIdentifier,
468 matcher = matcher,
469 regions = regions
470 )
471 }
472