1 /* 2 * Copyright 2019 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.compose.ui.test 18 19 import androidx.compose.ui.semantics.SemanticsNode 20 21 /** 22 * Represents a semantics node and the path to fetch it from the semantics tree. One can interact 23 * with this node by performing actions such as [performClick], assertions such as 24 * [assertHasClickAction], or navigate to other nodes such as [onChildren]. 25 * 26 * An instance of [SemanticsNodeInteraction] can be obtained from 27 * [onNode][SemanticsNodeInteractionsProvider.onNode] and convenience methods that use a specific 28 * filter, such as [onNodeWithText]. 29 * 30 * Here you can see how you can locate a checkbox, click it and verify that it's checked: 31 * 32 * @sample androidx.compose.ui.test.samples.clickAndVerifyCheckbox 33 * 34 * [useUnmergedTree] is for tests with a special need to inspect implementation detail within 35 * children. For example: 36 * 37 * @sample androidx.compose.ui.test.samples.useUnmergedTree 38 */ 39 class SemanticsNodeInteraction 40 constructor( 41 internal val testContext: TestContext, 42 internal val useUnmergedTree: Boolean, 43 internal val selector: SemanticsSelector 44 ) { 45 constructor( 46 testContext: TestContext, 47 useUnmergedTree: Boolean, 48 matcher: SemanticsMatcher 49 ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher)) 50 51 /** 52 * Anytime we refresh semantics we capture it here. This is then presented to the user in case 53 * their tests fails deu to a missing node. This helps to see what was the last state of the 54 * node before it disappeared. We dump it to string because trying to dump the node later can 55 * result in failure as it gets detached from its layout. 56 */ 57 private var lastSeenSemantics: String? = null 58 fetchSemanticsNodesnull59 internal fun fetchSemanticsNodes( 60 atLeastOneRootRequired: Boolean, 61 errorMessageOnFail: String? = null, 62 skipDeactivatedNodes: Boolean = true 63 ): SelectionResult = 64 testContext.testOwner.getAllSemanticsNodes( 65 atLeastOneRootRequired = atLeastOneRootRequired, 66 useUnmergedTree = useUnmergedTree, 67 skipDeactivatedNodes = skipDeactivatedNodes 68 ) { 69 selector.map(it, errorMessageOnFail.orEmpty()) 70 } 71 72 /** 73 * Returns the semantics node captured by this object. 74 * 75 * Note: Accessing this object involves synchronization with your UI. If you are accessing this 76 * multiple times in one atomic operation, it is better to cache the result instead of calling 77 * this API multiple times. 78 * 79 * This will fail if there is 0 or multiple nodes matching. 80 * 81 * @throws AssertionError if 0 or multiple nodes found. 82 */ fetchSemanticsNodenull83 fun fetchSemanticsNode(errorMessageOnFail: String? = null): SemanticsNode { 84 return fetchOneOrThrow(errorMessageOnFail) 85 } 86 87 /** 88 * Asserts that no item was found or that the item is no longer in the hierarchy. 89 * 90 * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data. 91 * 92 * @throws [AssertionError] if the assert fails. 93 */ assertDoesNotExistnull94 fun assertDoesNotExist() { 95 val result = 96 fetchSemanticsNodes( 97 atLeastOneRootRequired = false, 98 errorMessageOnFail = "Failed: assertDoesNotExist." 99 ) 100 if (result.selectedNodes.isNotEmpty()) { 101 throw AssertionError( 102 buildErrorMessageForCountMismatch( 103 errorMessage = "Failed: assertDoesNotExist.", 104 selector = selector, 105 foundNodes = result.selectedNodes, 106 expectedCount = 0 107 ) 108 ) 109 } 110 } 111 112 /** 113 * Asserts that the component was found and is part of the component tree. 114 * 115 * This will synchronize with the UI and fetch all the nodes again to ensure it has latest data. 116 * If you are using [fetchSemanticsNode] you don't need to call this. In fact you would just 117 * introduce additional overhead. 118 * 119 * @param errorMessageOnFail Error message prefix to be added to the message in case this 120 * asserts fails. This is typically used by operations that rely on this assert. Example 121 * prefix could be: "Failed to perform doOnClick.". 122 * @throws [AssertionError] if the assert fails. 123 */ assertExistsnull124 fun assertExists(errorMessageOnFail: String? = null): SemanticsNodeInteraction { 125 fetchOneOrThrow(errorMessageOnFail) 126 return this 127 } 128 129 /** 130 * Asserts that the component was found and it is deactivated. 131 * 132 * For example, the children of [androidx.compose.ui.layout.SubcomposeLayout] which are retained 133 * to be reused in future are considered deactivated. 134 * 135 * @throws [AssertionError] if the assert fails. 136 */ assertIsDeactivatednull137 fun assertIsDeactivated(errorMessageOnFail: String? = null) { 138 val node = fetchOneOrThrow(skipDeactivatedNodes = false) 139 if (!node.layoutInfo.isDeactivated) { 140 throw AssertionError( 141 buildGeneralErrorMessage( 142 errorMessage = errorMessageOnFail ?: "Failed: assertDeactivated", 143 selector = selector, 144 node = node 145 ) 146 ) 147 } 148 } 149 fetchOneOrThrownull150 private fun fetchOneOrThrow( 151 errorMessageOnFail: String? = null, 152 skipDeactivatedNodes: Boolean = true 153 ): SemanticsNode { 154 val finalErrorMessage = errorMessageOnFail ?: "Failed: assertExists." 155 156 val result = 157 fetchSemanticsNodes( 158 atLeastOneRootRequired = true, 159 errorMessageOnFail = finalErrorMessage, 160 skipDeactivatedNodes = skipDeactivatedNodes 161 ) 162 if (result.selectedNodes.count() != 1) { 163 if (result.selectedNodes.isEmpty() && lastSeenSemantics != null) { 164 // This means that node we used to have is no longer in the tree. 165 throw AssertionError( 166 buildErrorMessageForNodeMissingInTree( 167 errorMessage = finalErrorMessage, 168 selector = selector, 169 lastSeenSemantics = lastSeenSemantics!! 170 ) 171 ) 172 } 173 174 if (result.customErrorOnNoMatch != null) { 175 throw AssertionError(finalErrorMessage + "\n" + result.customErrorOnNoMatch) 176 } 177 178 throw AssertionError( 179 buildErrorMessageForCountMismatch( 180 errorMessage = finalErrorMessage, 181 foundNodes = result.selectedNodes, 182 expectedCount = 1, 183 selector = selector, 184 foundNodesUnmerged = getNodesInUnmergedTree(errorMessageOnFail) 185 ) 186 ) 187 } 188 189 lastSeenSemantics = result.selectedNodes.first().printToString() 190 return result.selectedNodes.first() 191 } 192 193 /** If using the merged tree, performs the same search in the unmerged tree. */ getNodesInUnmergedTreenull194 private fun getNodesInUnmergedTree(errorMessageOnFail: String?): List<SemanticsNode> { 195 return if (!useUnmergedTree) { 196 testContext.testOwner.getAllSemanticsNodes( 197 atLeastOneRootRequired = true, 198 useUnmergedTree = true 199 ) { 200 selector.map(it, errorMessageOnFail.orEmpty()).selectedNodes 201 } 202 } else { 203 emptyList() 204 } 205 } 206 } 207 208 /** 209 * Represents a collection of semantics nodes and the path to fetch them from the semantics tree. 210 * One can interact with these nodes by performing assertions such as [assertCountEquals], or 211 * navigate to other nodes such as [get]. 212 * 213 * An instance of [SemanticsNodeInteractionCollection] can be obtained from 214 * [onAllNodes][SemanticsNodeInteractionsProvider.onAllNodes] and convenience methods that use a 215 * specific filter, such as [onAllNodesWithText]. 216 * 217 * For example, here is how you verify that there are exactly two clickable items: 218 * 219 * @sample androidx.compose.ui.test.samples.verifyTwoClickableNodes 220 */ 221 class SemanticsNodeInteractionCollection 222 constructor( 223 internal val testContext: TestContext, 224 internal val useUnmergedTree: Boolean, 225 internal val selector: SemanticsSelector 226 ) { 227 constructor( 228 testContext: TestContext, 229 useUnmergedTree: Boolean, 230 matcher: SemanticsMatcher 231 ) : this(testContext, useUnmergedTree, SemanticsSelector(matcher)) 232 233 /** 234 * Returns the semantics nodes captured by this object. 235 * 236 * Note: Accessing this object involves synchronization with your UI. If you are accessing this 237 * multiple times in one atomic operation, it is better to cache the result instead of calling 238 * this API multiple times. 239 * 240 * @param atLeastOneRootRequired Whether to throw an error in case there is no compose content 241 * in the current test app. 242 * @param errorMessageOnFail Custom error message to append when this fails to retrieve the 243 * nodes. 244 */ fetchSemanticsNodesnull245 fun fetchSemanticsNodes( 246 atLeastOneRootRequired: Boolean = true, 247 errorMessageOnFail: String? = null 248 ): List<SemanticsNode> { 249 return testContext.testOwner.getAllSemanticsNodes(atLeastOneRootRequired, useUnmergedTree) { 250 selector.map(it, errorMessageOnFail.orEmpty()).selectedNodes 251 } 252 } 253 254 /** 255 * Retrieve node at the given index of this collection. 256 * 257 * Any subsequent operation on its result will expect exactly one element found (unless 258 * [SemanticsNodeInteraction.assertDoesNotExist] is used) and will throw [AssertionError] if 259 * none or more than one element is found. 260 */ getnull261 operator fun get(index: Int): SemanticsNodeInteraction { 262 return SemanticsNodeInteraction( 263 testContext, 264 useUnmergedTree, 265 selector.addIndexSelector(index) 266 ) 267 } 268 } 269