Skip to content

Commit a68cdf3

Browse files
authored
dataconnect: add gradle plugin that enables sharing code from src/test to src/androidTest (#8098)
1 parent 534ef3b commit a68cdf3

5 files changed

Lines changed: 254 additions & 0 deletions

File tree

firebase-dataconnect/firebase-dataconnect.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ plugins {
2525
id("copy-google-services")
2626
alias(libs.plugins.kotlinx.serialization)
2727
id("com.google.firebase.dataconnect.gradle.plugin") apply false
28+
id("com.google.firebase.dataconnect.sharedtest")
2829
}
2930

3031
firebaseLibrary {

firebase-dataconnect/gradleplugin/plugin/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ gradlePlugin {
3838
id = "com.google.firebase.dataconnect.gradle.plugin"
3939
implementationClass = "com.google.firebase.dataconnect.gradle.plugin.DataConnectGradlePlugin"
4040
}
41+
create("sharedTest") {
42+
id = "com.google.firebase.dataconnect.sharedtest"
43+
implementationClass =
44+
"com.google.firebase.dataconnect.gradle.sharedtest.SharedWithAndroidTestPlugin"
45+
}
4146
}
4247
}
4348

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
package com.google.firebase.dataconnect.gradle.sharedtest
17+
18+
import org.gradle.api.DefaultTask
19+
import org.gradle.api.file.DirectoryProperty
20+
import org.gradle.api.logging.Logger
21+
import org.gradle.api.tasks.InputDirectory
22+
import org.gradle.api.tasks.Internal
23+
import org.gradle.api.tasks.Optional
24+
import org.gradle.api.tasks.OutputDirectory
25+
import org.gradle.api.tasks.TaskAction
26+
import java.io.File
27+
28+
/**
29+
* A Gradle task that copies Kotlin source files annotated with `@file:SharedWithAndroidTest` from
30+
* an input directory to an output directory.
31+
*
32+
* This driving motivation behind creating this task is to share test utilities or fixtures between
33+
* unit tests and Android instrumentation tests by copying the annotated files into the appropriate
34+
* source set before compilation.
35+
*/
36+
abstract class CopySharedWithAndroidTestFiles : DefaultTask() {
37+
38+
@get:Internal abstract val inputBaseDirectory: DirectoryProperty
39+
40+
@get:InputDirectory abstract val inputDirectory: DirectoryProperty
41+
42+
@get:OutputDirectory abstract val outputDirectory: DirectoryProperty
43+
44+
@TaskAction
45+
fun run() {
46+
val inputBaseDirectory: File = inputBaseDirectory.get().asFile
47+
val inputDirectory: File = inputDirectory.get().asFile
48+
val outputDirectory: File = outputDirectory.get().asFile
49+
50+
logger.info("inputBaseDirectory={}", inputBaseDirectory.absolutePath)
51+
logger.info("inputDirectory={}", inputDirectory.absolutePath)
52+
logger.info("outputDirectory={}", outputDirectory.absolutePath)
53+
54+
copyAnnotatedFiles(
55+
srcBaseDir = inputBaseDirectory,
56+
srcDir = inputDirectory,
57+
destDir = outputDirectory,
58+
taskPath = path,
59+
logger = logger,
60+
)
61+
}
62+
}
63+
64+
/**
65+
* Recursively searches through [srcDir] for Kotlin source files (`.kt`) that contain the target
66+
* annotation and copies them to [destDir], maintaining their relative directory structure.
67+
*
68+
* @param srcBaseDir The root directory to use when reporting file system paths in the generated
69+
* files, rather than their absolute paths, to avoid defeating build caches; this **MUST** be a
70+
* parent directory of [srcDir].
71+
* @param srcDir The root directory to search for annotated Kotlin files.
72+
* @param destDir The root directory where annotated files should be copied.
73+
* @param taskPath The full Gradle path of the task performing the copy.
74+
* @param logger The Gradle logger to use for logging the copy operations.
75+
*/
76+
private fun copyAnnotatedFiles(srcBaseDir: File, srcDir: File, destDir: File, taskPath: String, logger: Logger) {
77+
if (!srcDir.exists()) {
78+
logger.info("source directory was not found; no files copied")
79+
return
80+
}
81+
if (!srcDir.isDirectory) {
82+
logger.info("source directory is not a directory; no files copied")
83+
return
84+
}
85+
86+
val srcFilesToCopy = srcDir
87+
.walk()
88+
.filter { it.isFile && it.extension == "kt" && it.hasALineEqualToOneOf(sharedWithAndroidTestAnnotationLines) }
89+
.toList()
90+
91+
logger.info(
92+
"Found {} files in {} that have a line equal to one of: {}",
93+
srcFilesToCopy.size,
94+
srcDir,
95+
sharedWithAndroidTestAnnotationLines.joinToString { "\"$it\"" }
96+
)
97+
98+
if (srcFilesToCopy.isEmpty()) {
99+
logger.info("No files found to copy; no files copied")
100+
return
101+
}
102+
103+
srcFilesToCopy.forEachIndexed { fileIndex, srcFile ->
104+
val relativePath = srcFile.relativeTo(srcDir)
105+
val destFile = File(destDir, relativePath.path)
106+
logger.info("Copying file {}/{}: {} to {}", (fileIndex+1), srcFilesToCopy.size, srcFile, destFile)
107+
108+
destFile.parentFile?.mkdirs()
109+
110+
destFile.printWriter().use {
111+
val srcFileRelative = srcFile.absoluteFile.relativeTo(srcBaseDir.absoluteFile)
112+
it.println("// Generated from: $srcFileRelative")
113+
it.println("// Generated by Gradle task: $taskPath")
114+
it.println("//")
115+
it.println("// WARNING: This file is generated!")
116+
it.println("// Any changes made to this file will eventually be overwritten by the build.")
117+
it.println("// If changes are desired, make them in the original file instead.")
118+
it.println()
119+
120+
srcFile.useLines { lines ->
121+
lines.forEach { line ->
122+
it.println(line)
123+
}
124+
}
125+
}
126+
}
127+
}
128+
129+
private val sharedWithAndroidTestAnnotationLines = listOf(
130+
"@file:SharedWithAndroidTest",
131+
"@file:com.google.firebase.dataconnect.testutil.SharedWithAndroidTest",
132+
)
133+
134+
private fun File.hasALineEqualToOneOf(desiredLines: Collection<String>): Boolean =
135+
useLines { lines ->
136+
lines.any {
137+
it.trim() in desiredLines
138+
}
139+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2026 Google LLC
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 com.google.firebase.dataconnect.gradle.sharedtest
18+
19+
import com.android.build.api.variant.AndroidComponentsExtension
20+
import com.android.build.api.variant.HasAndroidTest
21+
import com.android.build.api.variant.Variant
22+
import java.util.Locale
23+
import org.gradle.api.Plugin
24+
import org.gradle.api.Project
25+
import org.gradle.api.plugins.ExtensionContainer
26+
import org.gradle.api.tasks.TaskContainer
27+
import org.gradle.kotlin.dsl.register
28+
29+
/**
30+
* A Gradle plugin that, when applied, makes all files in `src/test/kotlin` that are annotated with
31+
* `@file:SharedWithAndroidTest` available to code in `src/androidTest/kotlin`, enabling test
32+
* utilities written for unit tests to also be available to integration tests.
33+
*
34+
* This is achieved by adding a "code generation" step to the `androidTest` target which simply
35+
* copies the appropriately-annotated files into the "generated code" directory.
36+
*
37+
* To apply this plugin to an Android application or library, simply register the plugin alongside
38+
* other Gradle plugins in `build.gradle.kts`:
39+
*
40+
* ```
41+
* plugins {
42+
* // other plugins
43+
* id("com.google.firebase.dataconnect.sharedtest")
44+
* }
45+
* ```
46+
*/
47+
@Suppress("unused")
48+
abstract class SharedWithAndroidTestPlugin : Plugin<Project> {
49+
override fun apply(project: Project) = applyPlugin(project.extensions, project.tasks)
50+
}
51+
52+
private fun applyPlugin(extensions: ExtensionContainer, tasks: TaskContainer) {
53+
val androidComponents = extensions.getByType(AndroidComponentsExtension::class.java)
54+
androidComponents.onVariants { variant -> handleVariant(variant, tasks) }
55+
}
56+
57+
private fun handleVariant(variant: Variant, tasks: TaskContainer) {
58+
val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return
59+
val variantNameTitleCase = variant.name.replaceFirstChar { it.titlecase(Locale.US) }
60+
61+
val task =
62+
tasks.register<CopySharedWithAndroidTestFiles>(
63+
"copy${variantNameTitleCase}SharedWithAndroidTestFiles"
64+
) {
65+
val projectDirectory = project.layout.projectDirectory
66+
inputBaseDirectory.set(projectDirectory)
67+
inputDirectory.set(projectDirectory.dir("src/test/kotlin"))
68+
}
69+
70+
androidTest.sources.java!!.addGeneratedSourceDirectory(
71+
task,
72+
CopySharedWithAndroidTestFiles::outputDirectory
73+
)
74+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
@file:SharedWithAndroidTest
18+
19+
package com.google.firebase.dataconnect.testutil
20+
21+
/**
22+
* Annotation used to mark files that should be shared with the androidTest source set.
23+
*
24+
* This annotation is processed by the CopySharedWithAndroidTestFiles Gradle task. For performance
25+
* and simplicity, the task performs a rudimentary string-based check on the file's contents. To be
26+
* recognized, a line in the source file must, when trimmed, exactly equal either:
27+
* - `@file:SharedWithAndroidTest`
28+
* - `@file:com.google.firebase.dataconnect.testutil.SharedWithAndroidTest`
29+
*
30+
* Notably, "grouped syntax" like `@file:[JvmName("MyFile") SharedWithAndroidTest]` is NOT supported
31+
* and will not be recognized by the task.
32+
*/
33+
@Target(AnnotationTarget.FILE)
34+
@Retention(AnnotationRetention.SOURCE)
35+
internal annotation class SharedWithAndroidTest

0 commit comments

Comments
 (0)