Skip to content

Commit b75a4a6

Browse files
Refactored buildSrc Gradle-based tests around GradleFixture and reduce resource use. (#11370)
Improved GradleFixture usage. Moved common code in base class. Improved GradleFixture usage. Moved common code in base class. Merge branch 'master' into alexeyk/refactor-gradle-fixture Refactored to use Kotlin DSL in tests scripts. Share TestKit daemon across GradleFixture tests. The Groovy→Kotlin DSL conversion of test scripts pushed :buildSrc:test from 6 min to ~11 min because every test method killed its TestKit daemon, forcing kotlinc to recompile every .gradle.kts in the next test. Move testKitDir into a JVM-wide companion-object lazy val, remove the per-test stopDaemons() call, and reap daemons once from the shutdown hook. Shared daemons cut :buildSrc:test from 10 min 40 s down to 1 min 36 s on Kotlin DSL (and ~3.8× faster than the original Groovy DSL runtime). Fix MuzzleMavenRepoUtils.MUZZLE_REPOS, which cached MAVEN_REPOSITORY_PROXY in a daemon-static `by lazy` val. With per-test daemons the cache was harmless; with daemon reuse it leaked a stale repo URL into the next test's resolution and caused version-range queries to fail. Replace it with defaultMuzzleRepos(), a function that reads the env on each call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Fixed review comments Co-authored-by: alexey.kuznetsov <alexey.kuznetsov@datadoghq.com>
1 parent 7c12554 commit b75a4a6

13 files changed

Lines changed: 698 additions & 766 deletions

buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ import java.nio.file.Files
2020

2121
internal object MuzzleMavenRepoUtils {
2222
/**
23-
* Remote repositories used to query version ranges and fetch dependencies
23+
* Remote repositories used to query version ranges and fetch dependencies.
24+
*
25+
* This intentionally reads the environment on each access: Gradle daemons can
26+
* be reused across builds with different MAVEN_REPOSITORY_PROXY values.
2427
*/
2528
@JvmStatic
26-
val MUZZLE_REPOS: List<RemoteRepository> by lazy {
29+
fun defaultMuzzleRepos(): List<RemoteRepository> {
2730
val central = RemoteRepository.Builder("central", "default", "https://repo1.maven.org/maven2/").build()
2831
val mavenProxyUrl = System.getenv("MAVEN_REPOSITORY_PROXY")
29-
if (mavenProxyUrl == null) {
32+
return if (mavenProxyUrl == null) {
3033
listOf(central)
3134
} else {
3235
val proxy = RemoteRepository.Builder("central-proxy", "default", mavenProxyUrl).build()
@@ -70,7 +73,7 @@ internal object MuzzleMavenRepoUtils {
7073
muzzleDirective: MuzzleDirective,
7174
system: RepositorySystem,
7275
session: RepositorySystemSession,
73-
defaultRepos: List<RemoteRepository> = MUZZLE_REPOS
76+
defaultRepos: List<RemoteRepository> = defaultMuzzleRepos()
7477
): Set<MuzzleDirective> {
7578
val allVersionsArtifact = DefaultArtifact(
7679
muzzleDirective.group,
@@ -124,7 +127,7 @@ internal object MuzzleMavenRepoUtils {
124127
muzzleDirective: MuzzleDirective,
125128
system: RepositorySystem,
126129
session: RepositorySystemSession,
127-
defaultRepos: List<RemoteRepository> = MUZZLE_REPOS
130+
defaultRepos: List<RemoteRepository> = defaultMuzzleRepos()
128131
): VersionRangeResult {
129132
val directiveArtifact: Artifact = DefaultArtifact(
130133
muzzleDirective.group,
Lines changed: 183 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package datadog.gradle.plugin
22

3+
import datadog.gradle.plugin.GradleFixture.Companion.sharedTestKitDir
34
import org.gradle.testkit.runner.BuildResult
45
import org.gradle.testkit.runner.GradleRunner
56
import org.gradle.testkit.runner.UnexpectedBuildResultException
67
import org.intellij.lang.annotations.Language
8+
import org.junit.jupiter.api.io.TempDir
79
import org.w3c.dom.Document
810
import java.io.File
911
import java.nio.file.Files
@@ -13,132 +15,207 @@ import javax.xml.parsers.DocumentBuilderFactory
1315
* Base fixture for Gradle plugin integration tests.
1416
* Provides common functionality for setting up test projects and running Gradle builds.
1517
*/
16-
internal open class GradleFixture(protected val projectDir: File) {
17-
// Each fixture gets its own testkit dir in the system temp directory (NOT under
18-
// projectDir) so that JUnit's @TempDir cleanup doesn't race with daemon file locks.
19-
// See https://github.com/gradle/gradle/issues/12535
20-
// A fresh daemon is started per test — ensuring withEnvironment() vars (e.g.
21-
// MAVEN_REPOSITORY_PROXY) are correctly set on the daemon JVM and not inherited
22-
// from a previously-started daemon with a different test's environment.
23-
// A JVM shutdown hook removes the directory after all tests have run (and daemons
24-
// have been stopped), so file locks are guaranteed to be released by then.
25-
private val testKitDir: File by lazy {
26-
Files.createTempDirectory("gradle-testkit-").toFile().also { dir ->
27-
Runtime.getRuntime().addShutdownHook(Thread { dir.deleteRecursively() })
18+
open class GradleFixture {
19+
@TempDir
20+
protected lateinit var projectDir: File
21+
22+
private val testKitDir: File get() = sharedTestKitDir
23+
24+
companion object {
25+
// JVM-wide testkit dir shared across all GradleFixture instances. One daemon
26+
// pool serves every test method, so kotlinc work on .gradle.kts scripts is
27+
// amortized instead of re-paid per test (recovers the +77 % wall-time
28+
// regression introduced by the Groovy→Kotlin DSL conversion).
29+
//
30+
// Lives outside any @TempDir so JUnit cleanup never races with daemon file
31+
// locks. See https://github.com/gradle/gradle/issues/12535
32+
//
33+
// TestKit may reuse the same daemon for builds with different withEnvironment()
34+
// values, so build logic must not cache environment-derived state in daemon-static
35+
// fields.
36+
private val sharedTestKitDir: File by lazy {
37+
Files.createTempDirectory("gradle-testkit-").toFile().also { dir ->
38+
Runtime.getRuntime().addShutdownHook(Thread {
39+
stopDaemonsIn(dir)
40+
dir.deleteRecursively()
41+
})
42+
}
43+
}
44+
45+
/**
46+
* Kills Gradle daemons started by TestKit under the given testkit dir.
47+
*
48+
* The Gradle Tooling API (used by [GradleRunner]) always spawns a daemon and
49+
* provides no public API to stop it (https://github.com/gradle/gradle/issues/12535).
50+
* We replicate the strategy Gradle uses in its own integration tests
51+
* ([DaemonLogsAnalyzer.killAll()][1]):
52+
*
53+
* 1. Scan `<testkit>/daemon/<version>/` for log files matching
54+
* `DaemonLogConstants.DAEMON_LOG_PREFIX + pid + DaemonLogConstants.DAEMON_LOG_SUFFIX`,
55+
* i.e. `daemon-<pid>.out.log`.
56+
* 2. Extract the PID from the filename and kill the process.
57+
*
58+
* Trade-offs of the PID-from-filename approach:
59+
* - **PID recycling**: between the build finishing and `kill` being sent, the OS
60+
* could theoretically recycle the PID. Now that this only runs at JVM exit
61+
* (no longer per-test), the window is short — when called from the shutdown
62+
* hook all daemons we own are still alive — so the risk remains negligible.
63+
* - **Filename convention is internal**: Gradle's `DaemonLogConstants.DAEMON_LOG_PREFIX`
64+
* (`"daemon-"`) / `DAEMON_LOG_SUFFIX` (`".out.log"`) are not public API; a future
65+
* Gradle version could change them. The `toLongOrNull()` guard safely skips entries
66+
* that don't parse as a PID (including the UUID fallback Gradle uses when the PID
67+
* is unavailable).
68+
* - **Java 8 compatible**: uses `kill`/`taskkill` via [ProcessBuilder] instead of
69+
* `ProcessHandle` (Java 9+) because build logic targets JVM 1.8.
70+
*
71+
* [1]: https://github.com/gradle/gradle/blob/43b381d88/testing/internal-distribution-testing/src/main/groovy/org/gradle/integtests/fixtures/daemon/DaemonLogsAnalyzer.groovy
72+
*/
73+
private fun stopDaemonsIn(testKitDir: File) {
74+
val daemonDir = File(testKitDir, "daemon")
75+
if (!daemonDir.exists()) return
76+
77+
daemonDir.walkTopDown()
78+
.filter { it.isFile && it.name.endsWith(".out.log") && !it.name.startsWith("hs_err") }
79+
.forEach { logFile ->
80+
val pid = logFile.nameWithoutExtension // daemon-12345.out
81+
.removeSuffix(".out") // daemon-12345
82+
.removePrefix("daemon-") // 12345
83+
.toLongOrNull() ?: return@forEach // skip UUIDs / unparseable names
84+
85+
val isWindows = System.getProperty("os.name").lowercase().contains("win")
86+
val killProcess = if (isWindows) {
87+
ProcessBuilder("taskkill", "/F", "/PID", pid.toString())
88+
} else {
89+
ProcessBuilder("kill", pid.toString())
90+
}
91+
try {
92+
val process = killProcess.redirectErrorStream(true).start()
93+
process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
94+
} catch (_: Exception) {
95+
// best effort — daemon may already be stopped
96+
}
97+
}
2898
}
2999
}
30100

31101
/**
32102
* Runs Gradle with the specified arguments.
33103
*
34-
* After the build completes, any Gradle daemons started by TestKit are killed
35-
* so their file locks on the testkit cache are released before JUnit `@TempDir`
36-
* cleanup. See https://github.com/gradle/gradle/issues/12535
104+
* The TestKit daemon spawned by the first call and reused for every subsequent
105+
* call in the JVM (shared [testKitDir]) so Kotlin compilation of `.gradle.kts`
106+
* scripts amortizes across tests instead of being re-paid per test.
107+
* Daemons are reaped at JVM shutdown by the hook registered when
108+
* [sharedTestKitDir] is created.
37109
*
38110
* @param args Gradle task names and arguments
39111
* @param expectFailure Whether the build is expected to fail
40112
* @param env Environment variables to set (merged with system environment)
113+
* @param forwardOutput Forward the build's stdout/stderr to the test's output
114+
* @param gradleProjectDir Override the project directory used by Gradle (useful for git worktree tests);
115+
* defaults to the fixture's project directory.
41116
* @return The build result
42117
*/
43-
fun run(vararg args: String, expectFailure: Boolean = false, env: Map<String, String> = emptyMap()): BuildResult {
118+
fun run(
119+
vararg args: String,
120+
expectFailure: Boolean = false,
121+
env: Map<String, String> = emptyMap(),
122+
forwardOutput: Boolean = false,
123+
gradleProjectDir: File = projectDir,
124+
): BuildResult {
44125
val runner = GradleRunner.create()
45126
.withTestKitDir(testKitDir)
46127
.withPluginClasspath()
47-
.withProjectDir(projectDir)
128+
.withProjectDir(gradleProjectDir)
48129
// Using withDebug prevents starting a daemon, but it doesn't work with withEnvironment
49130
.withEnvironment(System.getenv() + env)
50131
.withArguments(*args)
132+
if (forwardOutput) {
133+
runner.forwardOutput()
134+
}
51135
return try {
52136
if (expectFailure) runner.buildAndFail() else runner.build()
53137
} catch (e: UnexpectedBuildResultException) {
54138
e.buildResult
55-
} finally {
56-
stopDaemons()
57139
}
58140
}
59141

60142
/**
61-
* Kills Gradle daemons started by TestKit for this fixture's testkit dir.
62-
*
63-
* The Gradle Tooling API (used by [GradleRunner]) always spawns a daemon and
64-
* provides no public API to stop it (https://github.com/gradle/gradle/issues/12535).
65-
* We replicate the strategy Gradle uses in its own integration tests
66-
* ([DaemonLogsAnalyzer.killAll()][1]):
67-
*
68-
* 1. Scan `<testkit>/daemon/<version>/` for log files matching
69-
* `DaemonLogConstants.DAEMON_LOG_PREFIX + pid + DaemonLogConstants.DAEMON_LOG_SUFFIX`,
70-
* i.e. `daemon-<pid>.out.log`.
71-
* 2. Extract the PID from the filename and kill the process.
143+
* Writes a file under the project directory, creating parent dirs as needed.
72144
*
73-
* Trade-offs of the PID-from-filename approach:
74-
* - **PID recycling**: between the build finishing and `kill` being sent, the OS
75-
* could theoretically recycle the PID. In practice the window is short
76-
* (the `finally` block runs immediately after the build) so the risk is negligible.
77-
* - **Filename convention is internal**: Gradle's `DaemonLogConstants.DAEMON_LOG_PREFIX`
78-
* (`"daemon-"`) / `DAEMON_LOG_SUFFIX` (`".out.log"`) are not public API; a future
79-
* Gradle version could change them. The `toLongOrNull()` guard safely skips entries
80-
* that don't parse as a PID (including the UUID fallback Gradle uses when the PID
81-
* is unavailable).
82-
* - **Java 8 compatible**: uses `kill`/`taskkill` via [ProcessBuilder] instead of
83-
* `ProcessHandle` (Java 9+) because build logic targets JVM 1.8.
84-
*
85-
* [1]: https://github.com/gradle/gradle/blob/43b381d88/testing/internal-distribution-testing/src/main/groovy/org/gradle/integtests/fixtures/daemon/DaemonLogsAnalyzer.groovy
145+
* @param path Path relative to the project directory
146+
* @param content File contents; passed through [String.trimIndent] before writing,
147+
* and a trailing newline is appended.
148+
* @param append If true, appends to any existing file instead of overwriting it.
149+
* Safe to call repeatedly to build content up across steps.
86150
*/
87-
private fun stopDaemons() {
88-
val daemonDir = File(testKitDir, "daemon")
89-
if (!daemonDir.exists()) return
90-
91-
daemonDir.walkTopDown()
92-
.filter { it.isFile && it.name.endsWith(".out.log") && !it.name.startsWith("hs_err") }
93-
.forEach { logFile ->
94-
val pid = logFile.nameWithoutExtension // daemon-12345.out
95-
.removeSuffix(".out") // daemon-12345
96-
.removePrefix("daemon-") // 12345
97-
.toLongOrNull() ?: return@forEach // skip UUIDs / unparseable names
98-
99-
val isWindows = System.getProperty("os.name").lowercase().contains("win")
100-
val killProcess = if (isWindows) {
101-
ProcessBuilder("taskkill", "/F", "/PID", pid.toString())
102-
} else {
103-
ProcessBuilder("kill", pid.toString())
104-
}
105-
try {
106-
val process = killProcess.redirectErrorStream(true).start()
107-
process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
108-
} catch (_: Exception) {
109-
// best effort — daemon may already be stopped
110-
}
111-
}
112-
}
151+
fun writeFile(path: String, content: String, append: Boolean = false): File =
152+
file(path).also {
153+
it.parentFile?.mkdirs()
154+
val text = content.trimIndent() + "\n"
155+
if (append) it.appendText(text) else it.writeText(text)
156+
}
113157

114158
/**
115-
* Adds a subproject to the build.
116-
* Updates settings.gradle and creates the build script for the subproject.
159+
* Adds a subproject to the build by appending an `include` line to settings.gradle.kts
160+
* and writing the subproject's build.gradle.kts.
117161
*
118162
* @param projectPath The project path (e.g., "dd-java-agent:instrumentation:other")
119163
* @param buildScript The build script content for the subproject
120164
*/
121-
fun addSubproject(projectPath: String, @Language("Groovy") buildScript: String) {
122-
// Add to settings.gradle
123-
val settingsFile = file("settings.gradle")
124-
if (settingsFile.exists()) {
125-
settingsFile.appendText("\ninclude ':$projectPath'")
126-
} else {
127-
settingsFile.writeText("include ':$projectPath'")
128-
}
165+
fun addSubproject(projectPath: String, @Language("kotlin") buildScript: String) {
166+
writeFile("settings.gradle.kts", """include(":$projectPath")""", append = true)
167+
writeFile("${projectPath.replace(':', '/')}/build.gradle.kts", buildScript)
168+
}
129169

130-
file("${projectPath.replace(':', '/')}/build.gradle")
131-
.writeText(buildScript.trimIndent())
170+
/**
171+
* Writes a Java source file under src/<sourceSet>/java.
172+
*
173+
* @param classNameOrPath Simple class name, fully qualified class name, or source path
174+
* @param sourceCode The Java source content
175+
* @param sourceSet The Gradle source set to write to
176+
* @param projectPath Optional Gradle project path; defaults to the root project
177+
*/
178+
fun writeJavaSource(
179+
classNameOrPath: String,
180+
@Language("JAVA") sourceCode: String,
181+
sourceSet: String = "main",
182+
projectPath: String? = null,
183+
) {
184+
val sourcePath = classNameOrPath.removeSuffix(".java").replace('.', '/') + ".java"
185+
val projectPrefix = projectPath
186+
?.removePrefix(":")
187+
?.replace(':', '/')
188+
?.let { "$it/" }
189+
.orEmpty()
190+
writeFile("${projectPrefix}src/$sourceSet/java/$sourcePath", sourceCode)
132191
}
133192

134193
/**
135-
* Writes the root project's build.gradle file.
194+
* Writes gradle.properties at the project root.
195+
*
196+
* @param content Properties content (trimIndent applied, trailing newline added)
197+
* @param append If true, appends to any existing file instead of overwriting
198+
*/
199+
fun writeGradleProperties(content: String, append: Boolean = false): File =
200+
writeFile("gradle.properties", content, append)
201+
202+
/**
203+
* Writes the root project's build.gradle.kts file.
136204
*
137205
* @param buildScript The build script content for the root project
206+
* @param append If true, appends to any existing file instead of overwriting
138207
*/
139-
fun writeRootProject(@Language("Groovy") buildScript: String) {
140-
file("build.gradle").writeText(buildScript.trimIndent())
141-
}
208+
fun writeRootProject(@Language("kotlin") buildScript: String, append: Boolean = false): File =
209+
writeFile("build.gradle.kts", buildScript, append)
210+
211+
/**
212+
* Writes the root project's settings.gradle.kts file.
213+
*
214+
* @param settingsScript The settings script content
215+
* @param append If true, appends to any existing file instead of overwriting
216+
*/
217+
fun writeSettings(@Language("kotlin") settingsScript: String, append: Boolean = false): File =
218+
writeFile("settings.gradle.kts", settingsScript, append)
142219

143220
/**
144221
* Parses an XML file into a DOM Document.
@@ -149,12 +226,26 @@ internal open class GradleFixture(protected val projectDir: File) {
149226
}
150227

151228
/**
152-
* Creates or gets a file in the project directory, ensuring parent directories exist.
229+
* Returns a File handle under the project directory.
230+
* Does not touch the filesystem.
153231
*/
154-
protected fun file(path: String, mkdirs: Boolean = true): File =
155-
File(projectDir, path).also { file ->
156-
if (mkdirs) {
157-
file.parentFile?.mkdirs()
158-
}
159-
}
232+
fun file(path: String): File = File(projectDir, path)
233+
234+
/**
235+
* Creates a directory under the project directory (including any missing parents)
236+
* and returns it.
237+
*/
238+
fun dir(path: String): File = file(path).also { it.mkdirs() }
239+
240+
/**
241+
* The Gradle build output directory (`projectDir/build`). Not created — Gradle
242+
* produces it during a build.
243+
*/
244+
val buildDir: File get() = File(projectDir, "build")
245+
246+
/**
247+
* Returns a File under the Gradle build output directory (`projectDir/build/...`).
248+
* Does NOT create parent dirs — these paths are read after a Gradle build produces them.
249+
*/
250+
fun buildFile(path: String): File = File(buildDir, path)
160251
}

0 commit comments

Comments
 (0)