1 /*
2 * Copyright (C) 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.utils.compose
18
19 import android.app.Activity
20 import android.app.Dialog
21 import android.os.Build
22 import androidx.compose.material3.MaterialTheme
23 import androidx.compose.material3.Surface
24 import androidx.compose.runtime.Composable
25 import androidx.compose.runtime.DisposableEffect
26 import androidx.compose.ui.focus.FocusManager
27 import androidx.compose.ui.platform.LocalFocusManager
28 import androidx.compose.ui.platform.ViewRootForTest
29 import androidx.compose.ui.test.SemanticsNodeInteraction
30 import androidx.compose.ui.test.junit4.createAndroidComposeRule
31 import androidx.compose.ui.test.onRoot
32 import com.android.compose.theme.PlatformTheme
33 import java.util.concurrent.TimeUnit
34 import org.junit.rules.RuleChain
35 import org.junit.rules.TestRule
36 import org.junit.runner.Description
37 import org.junit.runners.model.Statement
38 import platform.test.screenshot.BitmapDiffer
39 import platform.test.screenshot.DeviceEmulationRule
40 import platform.test.screenshot.DeviceEmulationSpec
41 import platform.test.screenshot.FontsRule
42 import platform.test.screenshot.GoldenPathManager
43 import platform.test.screenshot.HardwareRenderingRule
44 import platform.test.screenshot.MaterialYouColorsRule
45 import platform.test.screenshot.ScreenshotActivity
46 import platform.test.screenshot.ScreenshotAsserterFactory
47 import platform.test.screenshot.ScreenshotTestRule
48 import platform.test.screenshot.UnitTestBitmapMatcher
49 import platform.test.screenshot.captureToBitmapAsync
50 import platform.test.screenshot.dialogScreenshotTest
51
52 /** A rule for Compose screenshot diff tests. */
53 class ComposeScreenshotTestRule(
54 private val emulationSpec: DeviceEmulationSpec,
55 pathManager: GoldenPathManager,
56 private val screenshotRule: ScreenshotTestRule = ScreenshotTestRule(pathManager)
<lambda>null57 ) : TestRule, BitmapDiffer by screenshotRule, ScreenshotAsserterFactory by screenshotRule {
58 private val colorsRule = MaterialYouColorsRule()
59 private val fontsRule = FontsRule()
60 private val hardwareRenderingRule = HardwareRenderingRule()
61 private val deviceEmulationRule = DeviceEmulationRule(emulationSpec)
62 val composeRule = createAndroidComposeRule<ScreenshotActivity>()
63
64 private val commonRule =
65 RuleChain.outerRule(deviceEmulationRule)
66 .around(screenshotRule)
67 .around(composeRule)
68
69 // As denoted in `MaterialYouColorsRule` and `FontsRule`, these two rules need to come first,
70 // though their relative orders are not critical.
71 private val deviceRule = RuleChain.outerRule(colorsRule).around(commonRule)
72 private val roboRule =
73 RuleChain.outerRule(fontsRule)
74 .around(colorsRule)
75 .around(hardwareRenderingRule)
76 .around(commonRule)
77 private val matcher = UnitTestBitmapMatcher
78 private val isRobolectric = Build.FINGERPRINT.contains("robolectric")
79
80 override fun apply(base: Statement, description: Description): Statement {
81 val ruleToApply = if (isRobolectric) roboRule else deviceRule
82 return ruleToApply.apply(base, description)
83 }
84
85 /**
86 * Compare [content] with the golden image identified by [goldenIdentifier] in the context of
87 * [testSpec]. If [content] is `null`, we will take a screenshot of the current [composeRule]
88 * content.
89 */
90 fun screenshotTest(
91 goldenIdentifier: String,
92 clearFocus: Boolean = false,
93 beforeScreenshot: () -> Unit = {},
94 viewFinder: () -> SemanticsNodeInteraction = { composeRule.onRoot() },
95 content: (@Composable () -> Unit)? = null,
96 ) {
97 // Make sure that the activity draws full screen and fits the whole display instead of the
98 // system bars.
99 val activity = composeRule.activity
100 activity.mainExecutor.execute { activity.window.setDecorFitsSystemWindows(false) }
101
102 // Set the content using the AndroidComposeRule to make sure that the Activity is set up
103 // correctly.
104 if (content != null) {
105 var focusManager: FocusManager? = null
106
107 composeRule.setContent {
108 val focusManager = LocalFocusManager.current.also { focusManager = it }
109
110 PlatformTheme {
111 Surface(
112 color = MaterialTheme.colorScheme.background,
113 ) {
114 content()
115
116 // Clear the focus early. This disposable effect will run after any
117 // DisposableEffect in content() but will run before layout/drawing, so
118 // clearing focus early here will make sure we never draw a focused effect.
119 if (clearFocus) {
120 DisposableEffect(Unit) {
121 focusManager.clearFocus()
122 onDispose {}
123 }
124 }
125 }
126 }
127 }
128 beforeScreenshot()
129
130 // Make sure focus is still cleared after everything settles.
131 if (clearFocus) {
132 focusManager!!.clearFocus()
133 }
134 }
135 composeRule.waitForIdle()
136
137 val view = (viewFinder().fetchSemanticsNode().root as ViewRootForTest).view
138 val bitmap = view.captureToBitmapAsync().get(10, TimeUnit.SECONDS)
139 screenshotRule.assertBitmapAgainstGolden(bitmap, goldenIdentifier, matcher)
140 }
141
142 fun dialogScreenshotTest(
143 goldenIdentifier: String,
144 dialogProvider: (Activity) -> Dialog,
145 ) {
146 dialogScreenshotTest(
147 composeRule.activityRule,
148 screenshotRule,
149 matcher,
150 goldenIdentifier,
151 waitForIdle = { composeRule.waitForIdle() },
152 dialogProvider,
153 )
154 }
155 }
156