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