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