<lambda>null1 package com.android.onboarding.bedsteadonboarding
2
3 import android.app.ActivityOptions
4 import android.content.ContentValues
5 import android.content.Intent
6 import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
7 import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
8 import android.graphics.Bitmap
9 import android.os.Build
10 import android.util.Log
11 import androidx.test.platform.app.InstrumentationRegistry
12 import androidx.test.services.storage.TestStorage
13 import androidx.test.uiautomator.By
14 import androidx.test.uiautomator.UiDevice
15 import androidx.test.uiautomator.Until
16 import com.android.onboarding.bedsteadonboarding.annotations.OnboardingNodeScreenshot
17 import com.android.onboarding.bedsteadonboarding.annotations.TestNodes
18 import com.android.onboarding.bedsteadonboarding.contractutils.ContractExecutionEligibilityChecker
19 import com.android.onboarding.bedsteadonboarding.contractutils.ContractUtils
20 import com.android.onboarding.bedsteadonboarding.data.NodeData
21 import com.android.onboarding.bedsteadonboarding.fakes.FakeActivityNode
22 import com.android.onboarding.bedsteadonboarding.graph.OnboardingGraphProvider
23 import com.android.onboarding.bedsteadonboarding.logcat.LogcatReader
24 import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil
25 import com.android.onboarding.bedsteadonboarding.providers.ConfigProviderUtil.TEST_NODE_CLASS_COLUMN
26 import com.android.onboarding.bedsteadonboarding.utils.EventLogUtils.ONBOARDING_EVENT_LOG_TAG
27 import com.android.onboarding.contracts.ArgumentFreeOnboardingActivityApiContract
28 import com.android.onboarding.contracts.ArgumentFreeVoidOnboardingActivityApiContract
29 import com.android.onboarding.contracts.EXTRA_ONBOARDING_NODE_ID
30 import com.android.onboarding.contracts.OnboardingActivityApiContract
31 import com.android.onboarding.contracts.UNKNOWN_NODE_ID
32 import com.android.onboarding.contracts.VoidOnboardingActivityApiContract
33 import com.android.onboarding.contracts.annotations.OnboardingNode
34 import com.android.onboarding.contracts.annotations.OnboardingNodeMetadata
35 import com.android.onboarding.contracts.nodeId
36 import com.android.onboarding.nodes.OnboardingEvent
37 import com.android.onboarding.nodes.OnboardingGraph
38 import com.android.onboarding.nodes.OnboardingGraphLog
39 import java.lang.AssertionError
40 import java.lang.IllegalArgumentException
41 import java.lang.IllegalStateException
42 import java.time.Duration
43 import java.time.Instant
44 import kotlin.reflect.KClass
45 import org.junit.rules.TestRule
46 import org.junit.runner.Description
47 import org.junit.runners.model.Statement
48 /**
49 * TestRule to run before and after each test run. It will store all the test node related configs
50 * such as which nodes are allowed to execute.
51 */
52 class OnboardingTestsRule {
53
54 val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
55
56 private val instrumentationContext = InstrumentationRegistry.getInstrumentation().context
57 private val testStorage = TestStorage()
58
59 private lateinit var logcatReader: LogcatReader
60 private lateinit var testNodesConfiguration: TestNodesConfiguration
61
62 // Initial/ Source node of the graph. In a test multiple nodes could be launched or even a single
63 // node could be launched multiple times using [OnboardingTestRule#launch] call. Each such call
64 // would start a new graph and hence update [startNodeIdOfGraph] value.
65 private var startNodeIdOfGraph: Long = UNKNOWN_NODE_ID
66 private var hasOnboardingNodeScreenshotAnnotation: Boolean = false
67 private var hasTakenScreenshot: Boolean = false
68 private var currentTestName: String = ""
69 private var nameOfNodeToTakeScreenshot: String = ""
70 private var componentOfNodeToTakeScreenshot: String = ""
71
72 /**
73 * Initializes the states used by this test rule.
74 *
75 * This method should <b>not</b> be called directly. It is invoked internally by the [DeviceState]
76 * rule that is part of the Bedstead project.
77 */
78 fun init(description: Description) {
79 if (description.isTest) {
80 Log.e(TAG, "Setting up OnboardingTestsRule")
81
82 currentTestName = description.methodName
83
84 // Extract test configuration from all the annotations applied to the test.
85 testNodesConfiguration = extractTestNodesConfiguration(description)
86 // Start Reading OnboardingEvents Logs.
87 logcatReader = LogcatReader(filterTag = ONBOARDING_EVENT_LOG_TAG).apply { start() }
88 // Create an array of [ContentValues] representing the Test Configs.
89 val contentValuesForTestConfigs =
90 createContentValuesForAllowedNodes(testNodesConfiguration.allowedNodes)
91 /* Now that we have an array of [allowedNodes] present in [contentValuesForTestConfigs],
92 store them in all the apps in [appsStoringTestConfig] using their [TestContentProvider].
93 */
94 for (appPackageName in testNodesConfiguration.appsStoringTestConfig) {
95 insertTestConfigsInApp(contentValuesForTestConfigs, appPackageName)
96 }
97 }
98 }
99
100 /**
101 * Tears down the states used by this test rule.
102 *
103 * This method should <b>not</b> be called directly. It is invoked internally by the [DeviceState]
104 * rule that is part of the Bedstead project.
105 */
106 fun teardown() {
107 Log.e(TAG, "Tearing down OnboardingTestsRule")
108
109 logcatReader.stopReadingLogs()
110 deleteAllTestConfigs(testNodesConfiguration.appsStoringTestConfig)
111 if (hasOnboardingNodeScreenshotAnnotation && !hasTakenScreenshot) {
112 throw AssertionError("OnboardingTestRule#takeScreenshot() not called in this test.")
113 }
114 }
115
116 var graph: OnboardingGraphProvider? = null
117
118 /** Provides access to the onboarding graph to be used to query for events. */
119 fun graph(): OnboardingGraphProvider {
120 if (graph == null) {
121 graph = OnboardingGraphProvider(logcatReader)
122 }
123 return graph!!
124 }
125
126 /** Creates a fake activity node from given node contract [nodeToFake]. */
127 fun <I, O> fake(
128 nodeToFake: KClass<out OnboardingActivityApiContract<I, O>>
129 ): FakeActivityNode<I, O> {
130 val appsStoringFakeNodeConfig = testNodesConfiguration.appsStoringTestConfig.toMutableSet()
131 appsStoringFakeNodeConfig.add(extractNodeDataAndItsAppPackage(nodeToFake).second)
132 return FakeActivityNode(
133 appsStoringFakeNodeConfig = appsStoringFakeNodeConfig.toSet(),
134 activityNode = nodeToFake,
135 context = instrumentationContext,
136 )
137 }
138
139 /**
140 * Launches an activity using [OnboardingActivityApiContract]. It will wait indefinitely until the
141 * activity starts. Each call to this method will start a new graph.
142 */
143 fun <I, O> launchAndWaitForNodeToStart(
144 activityContract: OnboardingActivityApiContract<I, O>,
145 args: I,
146 ) {
147 ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
148 instrumentationContext,
149 activityContract::class.java,
150 )
151 val nodeIntent = createNodeIntent(activityContract, args)
152 startTrampolineActivity(activityContract, nodeIntent)
153 }
154
155 /**
156 * Launches an activity using [ArgumentFreeOnboardingActivityApiContract]. It will wait
157 * indefinitely until the activity starts. Each call to this method will start a new graph.
158 */
159 fun <O> launchAndWaitForNodeToStart(
160 activityContract: ArgumentFreeOnboardingActivityApiContract<O>
161 ) {
162 ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
163 instrumentationContext,
164 activityContract::class.java,
165 )
166 val nodeIntent = createNodeIntent(activityContract, Unit)
167 startTrampolineActivity(activityContract, nodeIntent)
168 }
169
170 /**
171 * Launches an activity which do not return result using [VoidOnboardingActivityApiContract] and
172 * contract arguments. It will wait indefinitely until the activity starts. Each call to this
173 * method will start a new graph.
174 */
175 fun <I> launchAndWaitForNodeToStart(
176 activityContract: VoidOnboardingActivityApiContract<I>,
177 args: I,
178 ) {
179 ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
180 instrumentationContext,
181 activityContract::class.java,
182 )
183 val nodeIntent = createNodeIntent(activityContract, args)
184 startTrampolineActivity(activityContract, nodeIntent)
185 }
186
187 /**
188 * Launches an activity which do not return result using
189 * [ArgumentFreeVoidOnboardingActivityApiContract]. It will wait indefinitely until the activity
190 * starts. Each call to this method will start a new graph.
191 */
192 fun launchAndWaitForNodeToStart(activityContract: ArgumentFreeVoidOnboardingActivityApiContract) {
193 ContractExecutionEligibilityChecker.terminateIfNodeIsTriggeredByTestAndIsNotAllowed(
194 instrumentationContext,
195 activityContract::class.java,
196 )
197 val nodeIntent = createNodeIntent(activityContract, Unit)
198 startTrampolineActivity(activityContract, nodeIntent)
199 }
200
201 /** Captures the current UI on the device and saves it to the test output file. */
202 fun takeScreenshot() {
203 Log.i(TAG, "Taking screenshot of node $nameOfNodeToTakeScreenshot")
204 hasTakenScreenshot = true
205
206 val screenshot = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
207 val outputStream = testStorage.openOutputFile(/* pathname= */ getScreenshotName())
208 // Convert the bitmap to PNG.
209 screenshot.compress(Bitmap.CompressFormat.PNG, /* quality= */ 100, outputStream)
210
211 // Adding node info to the metadata of the generated sponge.
212 testStorage.addOutputProperties(
213 mapOf(
214 "onboarding_screenshot_node" to nameOfNodeToTakeScreenshot,
215 "onboarding_screenshot_component" to componentOfNodeToTakeScreenshot,
216 )
217 )
218 }
219
220 /**
221 * Given the [contractClass] of node, it blocks the test until the node has completed execution.
222 * Currently it supports only activity nodes. Other node types will be handled later. There are 3
223 * scenarios for node completion for activities.
224 * 1. For nodes which return result, until that node has returned result i.e.,
225 * [ActivityNodeSetResult] event has been logged.
226 * 2. Some other node has been started. This is a proxy for representing that the node of interest
227 * has finished execution.
228 * 3. User-initiated exits like lifecycle events representing destroy, backpress, etc.
229 */
230 fun blockUntilNodeHasFinished(contractClass: KClass<*>) {
231 // Find contract identifier of the node of interest given its contract class.
232 val contractIdentifier = ContractUtils.getContractIdentifier(contractClass.java)
233 while (true) {
234 val graph = OnboardingGraph(getOnboardingEvents())
235 if (hasNodeFinishedExecution(contractIdentifier, graph)) return
236 // In the logcat, we have not received OnboardingEvent for given node, so wait for some
237 // time before checking again.
238 Thread.sleep(1000)
239 }
240 }
241
242 /**
243 * Given the [contractIdentifierOfNodeOfInterest] it queries the given [graph] to check if this
244 * node has finished execution.
245 */
246 internal fun hasNodeFinishedExecution(
247 contractIdentifierOfNodeOfInterest: String,
248 graph: OnboardingGraph,
249 ): Boolean {
250 // Find the [OnboardingGraph.Node] entry for the source node of graph.
251 val startNodes = graph.nodes.entries.filter { it.key == startNodeIdOfGraph }
252 if (startNodes.isEmpty()) return false
253 val startNode = startNodes.first()
254
255 val nodeIdOfInterest =
256 findEarliestStartedNodeWithGivenContract(
257 startNode.key,
258 graph,
259 contractIdentifierOfNodeOfInterest,
260 ) ?: return false
261 val nodeToWaitFor = graph.nodes[nodeIdOfInterest]!!
262
263 val events = nodeToWaitFor.events
264 // Unblock, once the node completion condition has reached.
265 return events.any {
266 it.source is OnboardingEvent.ActivityNodeFinished ||
267 isAnotherNodeAttemptedToBeExecutedForResult(it.source, contractIdentifierOfNodeOfInterest)
268 } || nodeToWaitFor.outgoingEdgesOfValidNodes.isNotEmpty()
269 }
270
271 /**
272 * This will do a bfs traversal of the given [graph] whose initial node is [startNodeId]. It will
273 * look for all nodes whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. Out of
274 * those it will return the one which was started first. If there is no node with
275 * contractIdentifier as [contractIdentifierOfNodeOfInterest] it will return null.
276 */
277 internal fun findEarliestStartedNodeWithGivenContract(
278 startNodeId: Long,
279 graph: OnboardingGraph,
280 contractIdentifierOfNodeOfInterest: String,
281 ): Long? {
282 val queue = ArrayDeque<Long>().apply { add(startNodeId) } // Add initial node
283 val visitedNodes = mutableSetOf<Long>()
284 var minStartTimeOfNodeOfInterest: Instant = Instant.MAX
285 var probableNodeOfInterest: Long? = null
286
287 while (queue.isNotEmpty()) {
288 val currentNode = graph.nodes[queue.removeFirst()]!!
289 visitedNodes.add(currentNode.id)
290 if (
291 ContractUtils.getContractIdentifierForNode(currentNode) ==
292 contractIdentifierOfNodeOfInterest && currentNode.start < minStartTimeOfNodeOfInterest
293 ) {
294 probableNodeOfInterest = currentNode.id
295 minStartTimeOfNodeOfInterest = currentNode.start
296 }
297
298 for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
299 if (!visitedNodes.contains(outgoingNode.node.id)) {
300 queue.add(outgoingNode.node.id)
301 }
302 }
303 }
304 return probableNodeOfInterest
305 }
306
307 internal fun getStartNodeIdOfGraph() = startNodeIdOfGraph
308
309 // Internal helper function to be used only in robolectric tests.
310 internal fun setStartNodeIdOfGraph(nodeId: Long) {
311 if (requireRobolectric()) {
312 startNodeIdOfGraph = nodeId
313 } else {
314 throw IllegalAccessException("Not allowed to access internal methods")
315 }
316 }
317
318 /**
319 * Creates an [Intent] to launch the node for contract [activityContract] using [contractArgs].
320 */
321 internal fun <I> createNodeIntent(
322 activityContract: OnboardingActivityApiContract<I, *>,
323 contractArgs: I,
324 ): Intent {
325 // Use reflection to invoke the [OnboardingActivityApiContract#performCreateIntent()] of
326 // [activityContract] to create [Intent]. We assume that the instrumented app is not proguarded.
327 val intentCreationMethod =
328 activityContract::class.java.methods.firstOrNull {
329 it.name == INTENT_CREATION_METHOD_NAME && it.parameterCount == 2
330 }
331
332 if (intentCreationMethod != null) {
333 intentCreationMethod.isAccessible = true
334 val nodeIntent =
335 intentCreationMethod.invoke(activityContract, instrumentationContext, contractArgs)
336 as? Intent ?: error("Unable to create valid Intent for contract $activityContract")
337 // Since we start the node for [activityContract] using startActivity from
338 // [TrampolineActivity], so the graph is broken, i.e. we can't know what is the nodeId of the
339 // [activityContract]. So as a workaround we set the nodeId in the [Intent] here itself.
340 startNodeIdOfGraph = nodeIntent.nodeId
341 Log.i(TAG, "Activity will be launched with nodeId $startNodeIdOfGraph")
342 nodeIntent.putExtra(EXTRA_ONBOARDING_NODE_ID, startNodeIdOfGraph)
343 return nodeIntent
344 } else {
345 throw IllegalStateException(
346 "Couldn't find $INTENT_CREATION_METHOD_NAME method for contract $activityContract"
347 )
348 }
349 }
350
351 /**
352 * Given the [contractIdentifierOfNodeOfInterest] of the activity node, find if it has started by
353 * querying the onboarding events [graph] for [ActivityNodeResumed] event. If the
354 * [ActivityNodeResumed] event is logged for the node then it means that the activity has started
355 * and is visible.
356 */
357 internal fun hasActivityNodeStarted(
358 contractIdentifierOfNodeOfInterest: String,
359 graph: OnboardingGraph,
360 ): Boolean {
361 // Find the [OnboardingGraph.Node] entry for the source node of graph.
362 val startNode = graph.nodes.entries.find { it.key == startNodeIdOfGraph } ?: return false
363 return hasEventOccurred(
364 OnboardingEvent.ActivityNodeResumed::class,
365 contractIdentifierOfNodeOfInterest,
366 startNode.key,
367 graph,
368 )
369 }
370
371 /**
372 * Returns if the event represented by its class [eventClassOfInterest] has occurred for the node
373 * whose contractIdentifier = [contractIdentifierOfNodeOfInterest]. This will do a bfs traversal
374 * of the given [graph] whose initial node is [startNodeId].
375 */
376 internal fun hasEventOccurred(
377 eventClassOfInterest: KClass<*>,
378 contractIdentifierOfNodeOfInterest: String,
379 startNodeId: Long,
380 graph: OnboardingGraph,
381 ): Boolean {
382 val queue = ArrayDeque<Long>().apply { add(startNodeId) } // Add initial node
383 val visitedNodes = mutableSetOf<Long>()
384 while (queue.isNotEmpty()) {
385 val currentNode = graph.nodes.getValue(queue.removeFirst())
386 visitedNodes.add(currentNode.id)
387 if (
388 ContractUtils.getContractIdentifierForNode(currentNode) ==
389 contractIdentifierOfNodeOfInterest &&
390 currentNode.spawnedEvents.any { it.source::class == eventClassOfInterest }
391 ) {
392 return true
393 }
394
395 for (outgoingNode in currentNode.outgoingEdgesOfValidNodes) {
396 if (!visitedNodes.contains(outgoingNode.node.id)) {
397 queue.add(outgoingNode.node.id)
398 }
399 }
400 }
401 return false
402 }
403
404 /**
405 * Checks if the activity launched has been validated using the correct contract with which it was
406 * launched. If not it will throw an error. Note that it will also throw an error if the activity
407 * was validated twice once using correct and another time using incorrect correct.
408 */
409 internal fun throwErrorIfActivityNotValidatedUsingCorrectContract(
410 onboardingEvents: Set<OnboardingEvent>,
411 contractIdentifierOfInterest: String,
412 ) {
413 val invalidEvent =
414 onboardingEvents.find {
415 isInvalidActivityNodeValidatingEvent(it, contractIdentifierOfInterest, startNodeIdOfGraph)
416 }
417 if (invalidEvent != null) {
418 error(
419 "Please use the correct contract to validate the launched activity. Invalid event is $invalidEvent"
420 )
421 }
422 }
423
424 private fun isInvalidActivityNodeValidatingEvent(
425 event: OnboardingEvent,
426 expectedContractIdentifier: String,
427 activityNodeId: Long,
428 ) =
429 event.nodeId == activityNodeId &&
430 event is OnboardingEvent.ActivityNodeValidating &&
431 ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
432 expectedContractIdentifier
433
434 /** Returns if the calling function is executed on robolectric. */
435 private fun requireRobolectric() = "robolectric" == Build.FINGERPRINT
436
437 /**
438 * Stores the test configs using the ContentProvider of the app with given [appPackageName].
439 *
440 * @param contentValues An array of ContentValues representing the test configurations.
441 * @param appPackageName package name of the app where test configs would be stored.
442 */
443 private fun insertTestConfigsInApp(contentValues: Array<ContentValues>, appPackageName: String) {
444 /* Delete test configurations stored by app before inserting new ones. */
445 deleteTestConfigsOfApp(appPackageName)
446 val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(appPackageName))
447 instrumentationContext.contentResolver.bulkInsert(uri, contentValues)
448 }
449
450 /** Returns the immutable set of OnboardingEvents read from logs */
451 private fun getOnboardingEvents(): Set<OnboardingEvent> {
452 val onboardingEvents = mutableSetOf<OnboardingEvent>()
453 for (logLines in logcatReader.getFilteredLogs()) {
454 // Is an onboarding graph line containing OnboardingEvent.
455 val encoded = logLines.split("${ONBOARDING_EVENT_LOG_TAG}: ")[1]
456
457 // Deserialize and store the OnboardingEvent in-memory.
458 val event = OnboardingEvent.deserialize(encoded)
459 onboardingEvents.add(event)
460 }
461 return onboardingEvents.toSet()
462 }
463
464 /**
465 * Returns [true] if a node other than the one with name [contractIdentifierOfNode], has been
466 * started for result
467 */
468 private fun isAnotherNodeAttemptedToBeExecutedForResult(
469 event: OnboardingEvent,
470 contractIdentifierOfNode: String,
471 ) =
472 (event is OnboardingEvent.ActivityNodeStartExecuteSynchronously) &&
473 (ContractUtils.getContractIdentifier(event.nodeComponent, event.nodeName) !=
474 contractIdentifierOfNode)
475
476 /**
477 * Start the [TrampolineActivity] in the app owning [activityContract]. The [Intent] for
478 * [TrampolineActivity] will also contain the [nodeIntent] to launch the actual intended node for
479 * [activityContract] as an extra.
480 */
481 private fun startTrampolineActivity(
482 activityContract: OnboardingActivityApiContract<*, *>,
483 nodeIntent: Intent,
484 ) {
485 val trampolineActivityIntent =
486 Intent(getTrampolineActivityIntentAction(activityContract)).apply {
487 putExtra(EXTRA_NODE_START_INTENT_KEY, nodeIntent)
488 flags = INTENT_FLAGS_START_IN_NEW_TASK
489 }
490
491 val activityOptions =
492 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
493 ActivityOptions.makeBasic().apply { isShareIdentityEnabled = true }
494 } else {
495 null
496 }
497
498 // Start activity and wait.
499 instrumentationContext.startActivity(trampolineActivityIntent, activityOptions?.toBundle())
500 waitForActivityLaunch(activityContract)
501 }
502
503 /** Get the [Intent] action to launch Trampoline Activity of the application owning [contract]. */
504 private fun getTrampolineActivityIntentAction(
505 contract: OnboardingActivityApiContract<*, *>
506 ): String {
507 val onboardingNode =
508 extractOnboardingNodeAnnotation(contract)
509 ?: error("Can't fetch OnboardingNode annotation for contract $contract")
510 val onboardingNodeMetadata = getOnboardingNodeMetadata(onboardingNode)
511
512 return "${onboardingNodeMetadata.packageName}${TRAMPOLINE_ACTIVITY_NAME}"
513 }
514
515 /**
516 * Extract the [OnboardingNode] annotation applied to activity [contract]. There should be exactly
517 * 1 such annotation and if there is none then null is returned.
518 */
519 private fun extractOnboardingNodeAnnotation(contract: OnboardingActivityApiContract<*, *>) =
520 contract::class.annotations.filterIsInstance<OnboardingNode>().firstOrNull()
521
522 /**
523 * From the [TestNodes] annotation, extract all the nodes which are allowed to execute and also
524 * the package name of the apps containing the nodes and store them in [TestNodesConfiguration].
525 */
526 private fun processTestNodesAnnotation(testNodes: TestNodes): TestNodesConfiguration {
527 val allowedNodes = mutableListOf<NodeData>()
528 val appsStoringTestConfig = mutableSetOf<String>(instrumentationContext.packageName)
529
530 for (node in testNodes.nodes) {
531 extractNodeDataAndItsAppPackage(node.contract).apply {
532 allowedNodes.add(first)
533 appsStoringTestConfig.add(second)
534 }
535 }
536 return TestNodesConfiguration(appsStoringTestConfig.toSet(), allowedNodes.toList())
537 }
538
539 /**
540 * From the [OnboardingNodeScreenshot] annotation, extract the allowed node to execute and also
541 * the package name of the app containing this node and store them in [TestNodesConfiguration].
542 */
543 private fun processOnboardingNodeScreenshotAnnotation(
544 onboardingNodeScreenshot: OnboardingNodeScreenshot
545 ): TestNodesConfiguration {
546 validateUiNode(onboardingNodeScreenshot.node.contract.annotations)
547 hasOnboardingNodeScreenshotAnnotation = true
548
549 var allowedNode: NodeData
550 val appsStoringTestConfig = mutableSetOf(instrumentationContext.packageName)
551
552 extractNodeDataAndItsAppPackage(onboardingNodeScreenshot.node.contract).apply {
553 allowedNode = first
554 appsStoringTestConfig.add(second)
555 }
556
557 return TestNodesConfiguration(appsStoringTestConfig.toSet(), listOf(allowedNode))
558 }
559
560 private fun validateUiNode(annotations: List<Annotation>) {
561 for (annotation in annotations) {
562 if (annotation is OnboardingNode) {
563 when (annotation.uiType) {
564 OnboardingNode.UiType.EDUCATION,
565 OnboardingNode.UiType.INPUT,
566 OnboardingNode.UiType.LOADING,
567 OnboardingNode.UiType.ERROR,
568 OnboardingNode.UiType.OTHER -> {
569 nameOfNodeToTakeScreenshot = annotation.name
570 componentOfNodeToTakeScreenshot = annotation.component
571 }
572 OnboardingNode.UiType.HOST,
573 OnboardingNode.UiType.NONE,
574 OnboardingNode.UiType.INVISIBLE ->
575 throw IllegalArgumentException(
576 "Not allowed to take screenshot for non-UI onboarding nodes."
577 )
578 }
579 }
580 }
581 }
582
583 /**
584 * Given a node's contract class, returns its [NodeData] representation and the package name of
585 * the app to which it belongs.
586 */
587 internal fun extractNodeDataAndItsAppPackage(nodeContract: KClass<*>): Pair<NodeData, String> {
588 for (annotation in nodeContract.annotations) {
589 if (annotation is OnboardingNode) {
590 val onboardingNodeMetadata = getOnboardingNodeMetadata(annotation)
591 return Pair(
592 NodeData(
593 allowedContractIdentifier = ContractUtils.getContractIdentifier(nodeContract.java)
594 ),
595 onboardingNodeMetadata.packageName,
596 )
597 }
598 }
599 error("$nodeContract class does not have OnboardingNode annotation")
600 }
601
602 private fun getOnboardingNodeMetadata(onboardingNode: OnboardingNode): OnboardingNodeMetadata {
603 val nodeMetadata = onboardingNode.component.split("/")
604 return when (nodeMetadata.size) {
605 1 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[0])
606 2 -> OnboardingNodeMetadata(nodeMetadata[0], nodeMetadata[1])
607 else -> error("OnboardingNode component ${onboardingNode.component} is invalid")
608 }
609 }
610
611 // Creates a list of ContentValues representing the list of allowedNodes.
612 private fun createContentValuesForAllowedNodes(
613 allowedNodes: List<NodeData>
614 ): Array<ContentValues> =
615 allowedNodes
616 .map { node ->
617 ContentValues().apply { put(TEST_NODE_CLASS_COLUMN, node.allowedContractIdentifier) }
618 }
619 .toTypedArray()
620
621 // Deletes the test configs stored all the apps listed in [appsStoringTestConfig] .
622 private fun deleteAllTestConfigs(appsStoringTestConfig: Set<String>) {
623 for (appPackageName in appsStoringTestConfig) {
624 deleteTestConfigsOfApp(appPackageName)
625 }
626 }
627
628 // Deletes the test configs stored by a given app.
629 private fun deleteTestConfigsOfApp(packageName: String) {
630 val uri = ConfigProviderUtil.getTestConfigUri(ConfigProviderUtil.getAuthority(packageName))
631 instrumentationContext.contentResolver.delete(uri, /* where= */ null, /* selectionArgs= */ null)
632 }
633
634 /**
635 * Extracts the [TestNodesConfiguration] from all annotations applied to [description].
636 *
637 * If no annotation is provided, then null will be returned.
638 */
639 private fun extractTestNodesConfiguration(description: Description): TestNodesConfiguration {
640 val testNodesAnnotationConfiguration =
641 extractTestNodesAnnotation(description)?.let { processTestNodesAnnotation(it) }
642 ?: TestNodesConfiguration()
643 val onboardingNodeScreenshotAnnotationTestConfiguration =
644 extractOnboardingNodeScreenshotAnnotation(description)?.let {
645 processOnboardingNodeScreenshotAnnotation(it)
646 } ?: TestNodesConfiguration()
647
648 return TestNodesConfiguration(
649 appsStoringTestConfig =
650 testNodesAnnotationConfiguration.appsStoringTestConfig +
651 onboardingNodeScreenshotAnnotationTestConfiguration.appsStoringTestConfig,
652 allowedNodes =
653 testNodesAnnotationConfiguration.allowedNodes +
654 onboardingNodeScreenshotAnnotationTestConfiguration.allowedNodes,
655 )
656 }
657
658 /**
659 * Extracts the [TestNodes] annotation applied to [description].
660 *
661 * There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
662 * returned.
663 */
664 private fun extractTestNodesAnnotation(description: Description) =
665 description.annotations?.filterIsInstance<TestNodes>()?.firstOrNull()
666
667 /**
668 * Extracts the [OnboardingNodeScreenshot] annotation applied to [description].
669 *
670 * There can only be a maximum of 1 such annotation, and if 0 is provided then null will be
671 * returned.
672 */
673 private fun extractOnboardingNodeScreenshotAnnotation(description: Description) =
674 description.annotations?.filterIsInstance<OnboardingNodeScreenshot>()?.firstOrNull()
675
676 /**
677 * Returns the screenshot path derived from the component name [componentOfNodeToTakeScreenshot],
678 * node name [nameOfNodeToTakeScreenshot] and test method name [currentTestName].
679 */
680 private fun getScreenshotName() =
681 "$componentOfNodeToTakeScreenshot/$nameOfNodeToTakeScreenshot/${currentTestName}.png"
682
683 private fun waitForActivityLaunch(activityContract: OnboardingActivityApiContract<*, *>) {
684 if (!requireRobolectric()) {
685 val contractIdentifier = ContractUtils.getContractIdentifier(activityContract::class.java)
686 val contractPackageName = extractNodeDataAndItsAppPackage(activityContract::class).second
687 while (true) {
688 val onboardingEvents = getOnboardingEvents()
689 throwErrorIfActivityNotValidatedUsingCorrectContract(onboardingEvents, contractIdentifier)
690 val graph = OnboardingGraph(onboardingEvents)
691 if (hasActivityNodeStarted(contractIdentifier, graph)) {
692 // Pause the test for up to 60 seconds, waiting for a top-level UI element (depth 0) from
693 // the package specified by [contractPackageName] to appear on the screen.
694 device.wait(
695 Until.hasObject(By.pkg(contractPackageName).depth(0)),
696 Duration.ofSeconds(60).toMillis(),
697 )
698 Thread.sleep(1000) // Wait an additional second in case UI automator is flaky.
699 return
700 }
701 Thread.sleep(100) // Check every 100 milliseconds.
702 }
703 }
704 }
705
706 /**
707 * Stores the test configuration obtained by processing test level annotations. This data will be
708 * later used to store the list of [allowedNodes] in all the apps given by [appsStoringTestConfig]
709 * using their [TestContentProvider].
710 */
711 private data class TestNodesConfiguration(
712 /**
713 * Stores the package name of apps where the test configs will be stored. This includes the app
714 * package name of allowed nodes and the instrumented app.
715 */
716 val appsStoringTestConfig: Set<String> = setOf(),
717
718 /** Stores the list of nodes which are allowed to execute in tests. */
719 val allowedNodes: List<NodeData> = listOf(),
720 )
721
722 companion object {
723 const val TAG = "OnboardingTestsRule"
724 const val EXTRA_NODE_START_INTENT_KEY =
725 "com.android.onboarding.bedsteadonboarding.activities.extra.NODE_START_INTENT"
726
727 private const val INTENT_CREATION_METHOD_NAME = "performCreateIntent"
728 private const val INTENT_FLAGS_START_IN_NEW_TASK =
729 FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK
730
731 // LINT.IfChange(activity_action_name)
732 private const val TRAMPOLINE_ACTIVITY_NAME = ".bedstead.onboarding.trampolineactivity"
733 // LINT.ThenChange(java/com/android/onboarding/bedsteadonboarding/AndroidManifest.xml:activity_action_name)
734 }
735 }
736