1 /*
<lambda>null2  * Copyright 2023 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.build
18 
19 import com.android.build.api.artifact.Artifacts
20 import com.android.build.api.artifact.SingleArtifact
21 import com.android.build.api.variant.AndroidComponentsExtension
22 import com.android.build.api.variant.ApplicationAndroidComponentsExtension
23 import com.android.build.api.variant.BuiltArtifactsLoader
24 import com.android.build.api.variant.HasDeviceTests
25 import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension
26 import javax.inject.Inject
27 import org.gradle.api.DefaultTask
28 import org.gradle.api.Project
29 import org.gradle.api.file.DirectoryProperty
30 import org.gradle.api.provider.ListProperty
31 import org.gradle.api.provider.Property
32 import org.gradle.api.provider.Provider
33 import org.gradle.api.tasks.Input
34 import org.gradle.api.tasks.InputFiles
35 import org.gradle.api.tasks.Internal
36 import org.gradle.api.tasks.Optional
37 import org.gradle.api.tasks.PathSensitive
38 import org.gradle.api.tasks.PathSensitivity
39 import org.gradle.api.tasks.TaskAction
40 import org.gradle.api.tasks.options.Option
41 import org.gradle.kotlin.dsl.getByType
42 import org.gradle.process.ExecOperations
43 import org.gradle.process.ExecSpec
44 import org.gradle.work.DisableCachingByDefault
45 
46 @DisableCachingByDefault(because = "Expected to rerun every time")
47 abstract class FtlRunner : DefaultTask() {
48     init {
49         group = "Verification"
50         description = "Runs devices tests in Firebase Test Lab filtered by --className"
51     }
52 
53     @get:Inject abstract val execOperations: ExecOperations
54 
55     @get:InputFiles
56     @get:PathSensitive(PathSensitivity.RELATIVE)
57     abstract val testFolder: DirectoryProperty
58 
59     @get:Internal abstract val testLoader: Property<BuiltArtifactsLoader>
60 
61     @get:InputFiles
62     @get:PathSensitive(PathSensitivity.RELATIVE)
63     @get:Optional
64     abstract val appFolder: DirectoryProperty
65 
66     @get:Internal abstract val appLoader: Property<BuiltArtifactsLoader>
67 
68     @get:Input abstract val apkPackageName: Property<String>
69 
70     @get:Optional
71     @get:Input
72     @get:Option(option = "className", description = "Fully qualified class name of a class to run")
73     abstract val className: Property<String>
74 
75     @get:Optional
76     @get:Input
77     @get:Option(option = "packageName", description = "Package name test classes to run")
78     abstract val packageName: Property<String>
79 
80     @get:Optional
81     @get:Input
82     @get:Option(option = "pullScreenshots", description = "true if screenshots should be pulled")
83     abstract val pullScreenshots: Property<String>
84 
85     @get:Optional
86     @get:Input
87     @get:Option(option = "testTimeout", description = "timeout to pass to FTL test runner")
88     abstract val testTimeout: Property<String>
89 
90     @get:Optional
91     @get:Input
92     @get:Option(
93         option = "instrumentationArgs",
94         description = "instrumentation arguments to pass to FTL test runner"
95     )
96     abstract val instrumentationArgs: Property<String>
97 
98     @get:Optional
99     @get:Input
100     @get:Option(
101         option = "api",
102         description =
103             "repeatable argument for which apis to run ftl tests on. " +
104                 "Only relevant to $FTL_ON_APIS_NAME. Can be 21, 26, 28, 30, 33, 34, 35."
105     )
106     abstract val apis: ListProperty<Int>
107 
108     @get:Optional
109     @get:Input
110     @get:Option(
111         option = "shardCount",
112         description = "Number of shards to split tests into (requires gcloud beta)"
113     )
114     abstract val shardCount: Property<Int>
115 
116     @get:Optional
117     @get:Input
118     @get:Option(
119         option = "excludeAnnotation",
120         description =
121             "Repeatable argument to exclude annotations. " +
122                 "Example: `--excludeAnnotation androidx.test.filters.FlakyTest`"
123     )
124     abstract val excludeAnnotations: ListProperty<String>
125 
126     @get:Input abstract val device: ListProperty<String>
127 
128     @TaskAction
129     fun execThings() {
130         if (!System.getenv().containsKey("GOOGLE_APPLICATION_CREDENTIALS")) {
131             throw Exception(
132                 "Running tests in FTL requires credentials, you have not set up " +
133                     "GOOGLE_APPLICATION_CREDENTIALS, follow go/androidx-dev#remote-build-cache"
134             )
135         }
136         val testApk =
137             testLoader.get().load(testFolder.get())
138                 ?: throw RuntimeException("Cannot load required APK for task: $name")
139         val testApkPath = testApk.elements.single().outputFile
140         val appApkPath =
141             if (appLoader.isPresent) {
142                 val appApk =
143                     appLoader.get().load(appFolder.get())
144                         ?: throw RuntimeException("Cannot load required APK for task: $name")
145                 appApk.elements.single().outputFile
146             } else {
147                 "gs://androidx-ftl-test-results/github-ci-action/placeholderApp/" +
148                     "d345c82828c355acc1432535153cf1dcf456e559c26f735346bf5f38859e0512.apk"
149             }
150         try {
151             execOperations.printCommandAndExec { it.commandLine("gcloud", "--version") }
152         } catch (_: Exception) {
153             throw Exception(
154                 "Missing gcloud, please follow go/androidx-dev#remote-build-cache to set it up"
155             )
156         }
157 
158         val filterList = buildList {
159             if (className.isPresent) add("class ${className.get()}")
160             if (packageName.isPresent) add("package ${packageName.get()}")
161             if (excludeAnnotations.isPresent) {
162                 addAll(excludeAnnotations.get().map { "notAnnotation $it" })
163             }
164         }
165         val hasFilters = filterList.isNotEmpty()
166         val filters = filterList.joinToString(separator = ",")
167 
168         val shouldPull = pullScreenshots.isPresent && pullScreenshots.get() == "true"
169 
170         val needsBeta = shardCount.isPresent
171         execOperations.printCommandAndExec {
172             it.commandLine(
173                 listOfNotNull(
174                     "gcloud",
175                     if (needsBeta) "beta" else null,
176                     "--project",
177                     "androidx-dev-prod",
178                     "firebase",
179                     "test",
180                     "android",
181                     "run",
182                     "--type",
183                     "instrumentation",
184                     "--no-performance-metrics",
185                     "--no-auto-google-login",
186                     "--app",
187                     appApkPath,
188                     "--test",
189                     testApkPath,
190                     if (hasFilters) "--test-targets" else null,
191                     if (hasFilters) filters else null,
192                     if (shouldPull) "--directories-to-pull" else null,
193                     if (shouldPull) {
194                         "/sdcard/Android/data/${apkPackageName.get()}/cache/androidx_screenshots"
195                     } else null,
196                     if (testTimeout.isPresent) "--timeout" else null,
197                     if (testTimeout.isPresent) testTimeout.get() else null,
198                     if (shardCount.isPresent) "--num-uniform-shards" else null,
199                     if (shardCount.isPresent) shardCount.get() else null,
200                     if (instrumentationArgs.isPresent) "--environment-variables" else null,
201                     if (instrumentationArgs.isPresent) instrumentationArgs.get() else null,
202                 ) + getDeviceArguments()
203             )
204         }
205     }
206 
207     private fun getDeviceArguments(): List<String> {
208         val devices = device.get().ifEmpty { readApis() }
209         return devices.flatMap { listOf("--device", "model=$it,locale=en_US,orientation=portrait") }
210     }
211 
212     private fun readApis(): Collection<String> {
213         val apis = apis.get()
214         if (apis.isEmpty()) {
215             throw RuntimeException("--api must be specified when using $FTL_ON_APIS_NAME.")
216         }
217 
218         val apisWithoutModels = apis.filter { it !in API_TO_MODEL_MAP }
219         if (apisWithoutModels.isNotEmpty()) {
220             throw RuntimeException("Unknown apis specified: ${apisWithoutModels.joinToString()}")
221         }
222 
223         return apis.map { API_TO_MODEL_MAP[it]!! }
224     }
225 }
226 
227 private const val NEXUS_6P = "Nexus6P,version=27"
228 private const val A10 = "a10,version=29"
229 private const val PETTYL = "pettyl,version=27"
230 private const val HWCOR = "HWCOR,version=27"
231 private const val Q2Q = "q2q,version=31"
232 
233 private const val PHYSICAL_PIXEL9 = "tokay,version=34"
234 private const val MEDIUM_PHONE_34 = "MediumPhone.arm,version=34"
235 private const val MEDIUM_PHONE_35 = "MediumPhone.arm,version=35"
236 private const val MEDIUM_PHONE_33 = "MediumPhone.arm,version=33"
237 private const val MEDIUM_PHONE_30 = "MediumPhone.arm,version=30"
238 private const val MEDIUM_PHONE_28 = "MediumPhone.arm,version=28"
239 private const val MEDIUM_PHONE_26 = "MediumPhone.arm,version=26"
240 private const val NEXUS4_21 = "Nexus4.gce_x86,version=21"
241 private const val PIXEL2_33 = "Pixel2.arm,version=33"
242 private const val PIXEL2_30 = "Pixel2.arm,version=30"
243 private const val PIXEL2_28 = "Pixel2.arm,version=28"
244 private const val PIXEL2_26 = "Pixel2.arm,version=26"
245 
246 private val API_TO_MODEL_MAP =
247     mapOf(
248         34 to MEDIUM_PHONE_34,
249         35 to MEDIUM_PHONE_35,
250         33 to MEDIUM_PHONE_33,
251         30 to MEDIUM_PHONE_30,
252         28 to MEDIUM_PHONE_28,
253         26 to MEDIUM_PHONE_26,
254         21 to NEXUS4_21,
255     )
256 
257 private const val FTL_ON_APIS_NAME = "ftlOnApis"
258 private val devicesToRunOn =
259     listOf(
260         FTL_ON_APIS_NAME to listOf(), // instead read devices via repeatable --api
261         "ftlphysicalpixel9api34" to listOf(PHYSICAL_PIXEL9),
262         "ftlmediumphoneapi34" to listOf(MEDIUM_PHONE_34),
263         "ftlmediumphoneapi35" to listOf(MEDIUM_PHONE_35),
264         "ftlmediumphoneapi33" to listOf(MEDIUM_PHONE_33),
265         "ftlmediumphoneapi30" to listOf(MEDIUM_PHONE_30),
266         "ftlmediumphoneapi28" to listOf(MEDIUM_PHONE_28),
267         "ftlmediumphoneapi26" to listOf(MEDIUM_PHONE_26),
268         "ftlnexus4api21" to listOf(NEXUS4_21),
269         "ftlCoreTelecomDeviceSet" to listOf(NEXUS_6P, A10, PETTYL, HWCOR, Q2Q),
270         "ftlpixel2api33" to listOf(PIXEL2_33),
271         "ftlpixel2api30" to listOf(PIXEL2_30),
272         "ftlpixel2api28" to listOf(PIXEL2_28),
273         "ftlpixel2api26" to listOf(PIXEL2_26),
274     )
275 
registerRunnernull276 internal fun Project.registerRunner(
277     name: String,
278     artifacts: Artifacts,
279     namespace: Provider<String>
280 ) {
281     devicesToRunOn.forEach { (taskPrefix, model) ->
282         tasks.register("$taskPrefix$name", FtlRunner::class.java) { task ->
283             task.device.set(model)
284             task.apkPackageName.set(namespace)
285             task.testFolder.set(artifacts.get(SingleArtifact.APK))
286             task.testLoader.set(artifacts.getBuiltArtifactsLoader())
287         }
288     }
289 }
290 
Projectnull291 fun Project.configureFtlRunner(androidComponentsExtension: AndroidComponentsExtension<*, *, *>) {
292     androidComponentsExtension.apply {
293         onVariants { variant ->
294             when {
295                 variant is HasDeviceTests -> {
296                     variant.deviceTests.forEach { (_, deviceTest) ->
297                         registerRunner(deviceTest.name, deviceTest.artifacts, deviceTest.namespace)
298                     }
299                 }
300                 project.plugins.hasPlugin("com.android.test") -> {
301                     registerRunner(variant.name, variant.artifacts, variant.namespace)
302                 }
303             }
304         }
305     }
306 }
307 
Projectnull308 fun Project.configureFtlRunner(componentsExtension: KotlinMultiplatformAndroidComponentsExtension) {
309     componentsExtension.onVariant { variant ->
310         variant.deviceTests.forEach { (_, deviceTest) ->
311             registerRunner(deviceTest.name, deviceTest.artifacts, deviceTest.namespace)
312         }
313     }
314 }
315 
Projectnull316 fun Project.addAppApkToFtlRunner() {
317     extensions.getByType<ApplicationAndroidComponentsExtension>().apply {
318         onVariants(selector().withBuildType("debug")) { appVariant ->
319             devicesToRunOn.forEach { (taskPrefix, _) ->
320                 tasks.named("$taskPrefix${appVariant.name}AndroidTest") { configTask ->
321                     configTask as FtlRunner
322                     configTask.appFolder.set(appVariant.artifacts.get(SingleArtifact.APK))
323                     configTask.appLoader.set(appVariant.artifacts.getBuiltArtifactsLoader())
324                 }
325             }
326         }
327     }
328 }
329 
ExecOperationsnull330 private fun ExecOperations.printCommandAndExec(action: (ExecSpec) -> Unit) {
331     exec { spec ->
332         action(spec)
333 
334         // Just approximating the command for user verification.
335         val commandLine = spec.commandLine.map { if (" " in it) "\"$it\"" else it }
336         println("Executing command: `${commandLine.joinToString(" ")}`")
337     }
338 }
339