11package datadog.gradle.plugin
22
3+ import datadog.gradle.plugin.GradleFixture.Companion.sharedTestKitDir
34import org.gradle.testkit.runner.BuildResult
45import org.gradle.testkit.runner.GradleRunner
56import org.gradle.testkit.runner.UnexpectedBuildResultException
67import org.intellij.lang.annotations.Language
8+ import org.junit.jupiter.api.io.TempDir
79import org.w3c.dom.Document
810import java.io.File
911import 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(" \n include ':$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