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