From a796b165fd4566fb4992665ad45c7e0802532bac Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Tue, 27 Oct 2026 18:21:14 +0100 Subject: [PATCH 1/5] fix: track smoke application artifacts in tests --- .../smoketest/SmokeTestAppExtension.kt | 40 ++++-- .../smoketest/SmokeTestAppEndToEndTest.kt | 130 ++++++++++++++++++ 2 files changed, 161 insertions(+), 9 deletions(-) diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt index d2fd3c60598..493bb8e0c54 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt @@ -3,6 +3,7 @@ package datadog.buildlogic.smoketest import org.gradle.api.Action import org.gradle.api.Project import org.gradle.api.artifacts.Configuration +import org.gradle.api.file.Directory import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFileProperty @@ -10,6 +11,7 @@ import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.testing.Test import org.gradle.jvm.toolchain.JavaLanguageVersion @@ -84,7 +86,7 @@ abstract class SmokeTestAppExtension @Inject constructor( /** * Register the nested-build task and wire the produced artifact into every `Test` task as - * a system property. Calling this triggers task registration; consumers that prefer to + * an input and a system property. Calling this triggers task registration; consumers that prefer to * register [NestedGradleBuild] manually can leave [gradleApp] uncalled. */ fun gradleApp(action: Action) { @@ -116,7 +118,12 @@ abstract class SmokeTestAppExtension @Inject constructor( projectJars.set(this@SmokeTestAppExtension.projectJars) } - wireTestTasks(taskProvider, artifactPath, sysProperty, spec.additionalSystemProperties) + wireTestTasks( + taskProvider.flatMap { it.applicationBuildDir }, + artifactPath, + sysProperty, + spec.additionalSystemProperties, + ) } /** Register a Maven nested-build task and expose its artifact to every `Test` task. */ @@ -156,21 +163,36 @@ abstract class SmokeTestAppExtension @Inject constructor( spec.buildTimeoutSeconds.orNull?.let(buildTimeoutSeconds::set) } - wireTestTasks(taskProvider, artifactPath, sysProperty, spec.additionalSystemProperties) + wireTestTasks( + taskProvider.flatMap { it.applicationBuildDir }, + artifactPath, + sysProperty, + spec.additionalSystemProperties, + ) } private fun wireTestTasks( - taskProvider: TaskProvider<*>, + applicationOutput: Provider, artifactPath: String, sysProperty: String, additionalSystemProperties: MapProperty, ) { - val artifactProvider: Provider = applicationBuildDir.file(artifactPath) - val extras = additionalSystemProperties.get().mapValues { (_, relativePath) -> - applicationBuildDir.file(relativePath) - } + val artifactProvider = applicationOutput.map { it.file(artifactPath) } + val extras = + additionalSystemProperties.get().mapValues { (_, relativePath) -> + applicationOutput.map { it.file(relativePath) } + } project.tasks.withType().configureEach { - dependsOn(taskProvider) + inputs + .file(artifactProvider) + .withPropertyName("smokeTestAppArtifact.$sysProperty") + .withPathSensitivity(PathSensitivity.NONE) + extras.forEach { (key, value) -> + inputs + .files(value) + .withPropertyName("smokeTestAppExtra.$key") + .withPathSensitivity(PathSensitivity.RELATIVE) + } jvmArgumentProviders.add(SmokeTestArgProvider(sysProperty, artifactProvider, extras)) } } diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt index 5f77a60ccab..4bea5807507 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt @@ -10,6 +10,7 @@ import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource +import org.junit.jupiter.params.provider.ValueSource import java.io.File import java.nio.file.Files import java.nio.file.Path @@ -122,6 +123,118 @@ class SmokeTestAppEndToEndTest { assertThat(applicationOutput("target/sample.jar")).exists() } + @ParameterizedTest(name = "{0}") + @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory"]) + fun `tests track nested application contents`(scenario: String) { + val maven = scenario == "Maven artifact" + val extra = scenario.startsWith("extra") + val directory = scenario == "extra directory" + val artifactPath = if (maven) "target/sample.jar" else "sample.jar" + val extraPath = if (directory) "extra" else "extra.txt" + val payloadPath = + if (extra) { + if (directory) "$extraPath/value.txt" else extraPath + } else { + "sample.jar" + } + + writeOuterSettings(withLocalBuildCache = true) + val application = + if (maven) { + writeFakeMavenWrapper() + File(applicationDir, "pom.xml").writeText("") + smokeTestMavenApplication( + taskName = "packageApp", + artifactPath = artifactPath, + sysProperty = "sample.path", + additionalConfig = "mavenExecutable.set(layout.projectDirectory.file(\"${fakeMavenWrapperName()}\"))", + ) + } else { + writeInnerSettings() + writeInnerBuild( + """ + tasks.register("packageApp") { + from(layout.projectDirectory.dir("payload")) + into(layout.buildDirectory) + } + """.trimIndent(), + ) + File(applicationDir, "payload").mkdirs() + File(applicationDir, "payload/sample.jar").writeText("jar") + File(applicationDir, "payload/$payloadPath").apply { + parentFile.mkdirs() + writeText("jar") + } + smokeTestGradleApplication( + taskName = "packageApp", + artifactPath = artifactPath, + sysProperty = "sample.path", + additionalConfig = if (extra) "additionalSystemProperties.put(\"sample.extra\", \"$extraPath\")" else "", + ) + } + writeSmokeTestAppBuild( + application, + extraPreamble = """ + dependencies { testImplementation(files(${junitClasspath()})) } + tasks.test { useJUnitPlatform() } + """, + ) + val property = if (extra) "sample.extra" else "sample.path" + val relativeFile = if (directory) ", \"value.txt\"" else "" + File(projectDir.toFile(), "src/test/java/SmokeArtifactTest.java").apply { + parentFile.mkdirs() + writeText( + """ + import java.nio.file.Files; + import java.nio.file.Path; + import org.junit.jupiter.api.Test; + import static org.junit.jupiter.api.Assertions.assertEquals; + + public class SmokeArtifactTest { + @Test void applicationWorks() throws Exception { + Path artifact = Path.of(System.getProperty("$property")$relativeFile); + assertEquals("jar", Files.readString(artifact).trim(), "smoke artifact"); + } + } + """.trimIndent(), + ) + } + + val first = runner("test", "--build-cache").build() + assertThat(first.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(first.task(":test")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(runner("test", "--build-cache").build().task(":test")?.outcome) + .isEqualTo(TaskOutcome.UP_TO_DATE) + + File(projectDir.toFile(), "build/test-results").deleteRecursively() + File(projectDir.toFile(), "build/reports/tests").deleteRecursively() + assertThat(runner("test", "--build-cache").build().task(":test")?.outcome) + .isEqualTo(TaskOutcome.FROM_CACHE) + + if (maven) { + File(applicationDir, "pom.xml").appendText("\n") + } else { + File(applicationDir, "payload/unrelated.txt").writeText("unrelated") + } + val unchangedArtifact = runner("test", "--build-cache").build() + assertThat(unchangedArtifact.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(unchangedArtifact.task(":test")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) + + if (maven) { + val original = if (isWindows()) "echo jar>" else "printf \"jar\"" + val wrapper = File(projectDir.toFile(), fakeMavenWrapperName()) + wrapper.writeText(wrapper.readText().replace(original, original.replace("jar", "broken"))) + } else { + File(applicationDir, "payload/$payloadPath").writeText("broken") + } + val changedArtifact = runner("test", "--build-cache").buildAndFail() + assertThat(changedArtifact.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(changedArtifact.task(":test")?.outcome).isEqualTo(TaskOutcome.FAILED) + assertThat(File(projectDir.toFile(), "build/test-results/test/TEST-SmokeArtifactTest.xml")) + .content() + .contains("smoke artifact", "broken") + } + @Test fun `nested build clears inherited Gradle launcher environment`() { writeOuterSettings() @@ -718,6 +831,23 @@ class SmokeTestAppEndToEndTest { "fake-mvnw" } + private fun junitClasspath() = + listOf( + "org.junit.jupiter.api.Test", + "org.junit.jupiter.engine.JupiterTestEngine", + "org.junit.platform.engine.TestEngine", + "org.junit.platform.launcher.Launcher", + "org.junit.platform.commons.JUnitException", + "org.opentest4j.AssertionFailedError", + ).joinToString(", ") { + "\"${Path.of( + Class + .forName(it) + .protectionDomain.codeSource.location + .toURI(), + ).toFile().invariantSeparatorsPath}\"" + } + private fun writeInnerSettings(additionalContent: String = "") { File(applicationDir, "settings.gradle.kts").writeText( """ From 02d3e614038f1b2b426bdea0fad32d3ff3847bd4 Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 10:07:58 +0100 Subject: [PATCH 2/5] fix: track GraalVM release metadata in smoke builds --- .../buildlogic/smoketest/NestedGradleBuild.kt | 6 + .../smoketest/SmokeTestAppEndToEndTest.kt | 142 ++++++++++++++++-- 2 files changed, 133 insertions(+), 15 deletions(-) diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt index 49fc7bcbb01..cd47f928c43 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt @@ -157,6 +157,12 @@ abstract class NestedGradleBuild @Inject constructor( @get:Input abstract val environment: MapProperty + /** GraalVM release metadata belongs to the native compiler, independently of the daemon JDK. */ + @get:Input + @get:Optional + val graalvmRelease: Provider + get() = environment.getting("GRAALVM_HOME").map { File(it, "release").readText() } + @get:Nested abstract val projectJars: ListProperty diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt index 4bea5807507..74dfe30dcd4 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt @@ -124,9 +124,10 @@ class SmokeTestAppEndToEndTest { } @ParameterizedTest(name = "{0}") - @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory"]) + @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory", "GraalVM release"]) fun `tests track nested application contents`(scenario: String) { val maven = scenario == "Maven artifact" + val graalvm = scenario == "GraalVM release" val extra = scenario.startsWith("extra") val directory = scenario == "extra directory" val artifactPath = if (maven) "target/sample.jar" else "sample.jar" @@ -152,13 +153,29 @@ class SmokeTestAppEndToEndTest { } else { writeInnerSettings() writeInnerBuild( - """ - tasks.register("packageApp") { - from(layout.projectDirectory.dir("payload")) - into(layout.buildDirectory) - } - """.trimIndent(), + if (graalvm) { + """ + tasks.register("packageApp") { + from(file(System.getenv("GRAALVM_HOME")).resolve("release")) + rename { "sample.jar" } + into(layout.buildDirectory) + } + """.trimIndent() + } else { + """ + tasks.register("packageApp") { + from(layout.projectDirectory.dir("payload")) + into(layout.buildDirectory) + } + """.trimIndent() + }, ) + if (graalvm) { + File(projectDir.toFile(), "graalvm/release").apply { + parentFile.mkdirs() + writeText("jar") + } + } File(applicationDir, "payload").mkdirs() File(applicationDir, "payload/sample.jar").writeText("jar") File(applicationDir, "payload/$payloadPath").apply { @@ -169,7 +186,12 @@ class SmokeTestAppEndToEndTest { taskName = "packageApp", artifactPath = artifactPath, sysProperty = "sample.path", - additionalConfig = if (extra) "additionalSystemProperties.put(\"sample.extra\", \"$extraPath\")" else "", + additionalConfig = + when { + graalvm -> "environment.put(\"GRAALVM_HOME\", layout.projectDirectory.dir(\"graalvm\").asFile.absolutePath)" + extra -> "additionalSystemProperties.put(\"sample.extra\", \"$extraPath\")" + else -> "" + }, ) } writeSmokeTestAppBuild( @@ -203,13 +225,20 @@ class SmokeTestAppEndToEndTest { val first = runner("test", "--build-cache").build() assertThat(first.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) assertThat(first.task(":test")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(runner("test", "--build-cache").build().task(":test")?.outcome) - .isEqualTo(TaskOutcome.UP_TO_DATE) + val unchanged = runner("test", "--build-cache").build() + assertThat(unchanged.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) + assertThat(unchanged.task(":test")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) File(projectDir.toFile(), "build/test-results").deleteRecursively() File(projectDir.toFile(), "build/reports/tests").deleteRecursively() - assertThat(runner("test", "--build-cache").build().task(":test")?.outcome) - .isEqualTo(TaskOutcome.FROM_CACHE) + if (graalvm) { + applicationBuildDir.deleteRecursively() + } + val restored = runner("test", "--build-cache").build() + if (graalvm) { + assertThat(restored.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) + } + assertThat(restored.task(":test")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) if (maven) { File(applicationDir, "pom.xml").appendText("\n") @@ -220,7 +249,9 @@ class SmokeTestAppEndToEndTest { assertThat(unchangedArtifact.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) assertThat(unchangedArtifact.task(":test")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) - if (maven) { + if (graalvm) { + File(projectDir.toFile(), "graalvm/release").writeText("broken") + } else if (maven) { val original = if (isWindows()) "echo jar>" else "printf \"jar\"" val wrapper = File(projectDir.toFile(), fakeMavenWrapperName()) wrapper.writeText(wrapper.readText().replace(original, original.replace("jar", "broken"))) @@ -314,6 +345,9 @@ class SmokeTestAppEndToEndTest { @Test fun `nested build receives native app environment and provider backed file inputs`() { writeOuterSettings() + val graalvmHome = projectDir.resolve("test-graalvm").toFile() + graalvmHome.mkdirs() + File(graalvmHome, "release").writeText("GRAALVM_VERSION=25.0.1") File(projectDir.toFile(), "agent.jar").writeText("agent") writeSmokeTestAppBuild( """ @@ -323,7 +357,7 @@ class SmokeTestAppEndToEndTest { sysProperty = "native.inputs.path", additionalConfig = """ buildArguments.add("-Dnative.enabled=true") - environment.put("GRAALVM_HOME", providers.provider { "test-graalvm" }) + environment.put("GRAALVM_HOME", providers.provider { layout.projectDirectory.dir("test-graalvm").asFile.absolutePath }) """, )} projectJar("agentPath", providers.provider { layout.projectDirectory.file("agent.jar") }) @@ -354,12 +388,90 @@ class SmokeTestAppEndToEndTest { val inputsFile = applicationOutput("native-inputs.txt") assertThat(inputsFile).exists() assertThat(inputsFile.readLines()).contains( - "graalvm=test-graalvm", + "graalvm=${graalvmHome.canonicalPath}", "agentPath=${projectDir.resolve("agent.jar").toFile().canonicalPath}", "nativeEnabled=true", ) } + @Test + fun `unselected and skipped native builds do not resolve GraalVM home`() { + writeOuterSettings() + writeSmokeTestAppBuild( + smokeTestGradleApplication( + taskName = "nativeBuild", + artifactPath = "native-app", + sysProperty = "native.path", + additionalConfig = """ + environment.put("GRAALVM_HOME", providers.provider { error("GraalVM home queried") }) + """, + ), + extraImports = "import datadog.buildlogic.smoketest.NestedGradleBuild", + extraPreamble = """ + tasks.withType().configureEach { onlyIf { false } } + """, + ) + + assertThat(runner("help", "--configuration-cache").build().task(":nativeBuild")).isNull() + assertThat(runner("nativeBuild").build().task(":nativeBuild")?.outcome) + .isEqualTo(TaskOutcome.SKIPPED) + + outerBuild.writeText( + outerBuild + .readText() + .replace("error(\"GraalVM home queried\")", "layout.projectDirectory.dir(\"missing-graalvm\").asFile.absolutePath"), + ) + val skipped = runner("nativeBuild", "--configuration-cache").build() + assertThat(skipped.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SKIPPED) + + outerBuild.writeText(outerBuild.readText().replace("onlyIf { false }", "onlyIf { true }")) + val missingRelease = runner("nativeBuild").buildAndFail() + assertThat(missingRelease.output).contains("missing-graalvm", "release") + } + + @Test + fun `GraalVM release changes remain visible with the configuration cache`() { + writeOuterSettings() + val release = projectDir.resolve("graalvm/release").toFile() + release.parentFile.mkdirs() + release.writeText("first") + writeSmokeTestAppBuild( + smokeTestGradleApplication( + taskName = "nativeBuild", + artifactPath = "native-app.txt", + sysProperty = "native.path", + additionalConfig = "environment.put(\"GRAALVM_HOME\", layout.projectDirectory.dir(\"graalvm\").asFile.absolutePath)", + ), + extraImports = "import datadog.buildlogic.smoketest.NestedGradleBuild", + extraPreamble = """ + tasks.withType().configureEach { + actions.clear() + val metadata = graalvmRelease + val target = applicationBuildDir.file("native-app.txt") + doLast { + target.get().asFile.apply { + parentFile.mkdirs() + writeText(metadata.get()) + } + } + } + """, + ) + + val first = runner("nativeBuild", "--configuration-cache").build() + assertThat(first.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(applicationOutput("native-app.txt")).content().isEqualTo("first") + + val unchanged = runner("nativeBuild", "--configuration-cache").build() + assertThat(unchanged.output).contains("Reusing configuration cache") + assertThat(unchanged.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) + + release.writeText("second") + val changed = runner("nativeBuild", "--configuration-cache").build() + assertThat(changed.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(applicationOutput("native-app.txt")).content().isEqualTo("second") + } + @Test fun `init scripts are not added outside CI`() { writeOuterSettings() From 4b6b49c41a4c58548a4fc69e090540b2c3a9626d Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 10:49:16 +0100 Subject: [PATCH 3/5] refactor: declare smoke build inputs through the DSL --- .../buildlogic/smoketest/NestedGradleBuild.kt | 6 - .../smoketest/SmokeTestAppExtension.kt | 14 ++ .../smoketest/SmokeTestAppEndToEndTest.kt | 172 +++++++++++++----- dd-smoke-tests/quarkus-native/build.gradle | 6 + .../spring-boot-3.0-native/build.gradle | 6 + 5 files changed, 155 insertions(+), 49 deletions(-) diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt index cd47f928c43..49fc7bcbb01 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/NestedGradleBuild.kt @@ -157,12 +157,6 @@ abstract class NestedGradleBuild @Inject constructor( @get:Input abstract val environment: MapProperty - /** GraalVM release metadata belongs to the native compiler, independently of the daemon JDK. */ - @get:Input - @get:Optional - val graalvmRelease: Provider - get() = environment.getting("GRAALVM_HOME").map { File(it, "release").readText() } - @get:Nested abstract val projectJars: ListProperty diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt index 493bb8e0c54..a5e09d5f153 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt @@ -12,6 +12,7 @@ import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskInputs import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.testing.Test import org.gradle.jvm.toolchain.JavaLanguageVersion @@ -116,6 +117,7 @@ abstract class SmokeTestAppExtension @Inject constructor( buildCacheEnabled.set(spec.buildCacheEnabled) spec.stopTimeoutSeconds.orNull?.let(stopTimeoutSeconds::set) projectJars.set(this@SmokeTestAppExtension.projectJars) + spec.applyInputs(inputs) } wireTestTasks( @@ -161,6 +163,7 @@ abstract class SmokeTestAppExtension @Inject constructor( useMavenLocalRepository.set(spec.useMavenLocalRepository) mavenLocalRepository.set(spec.mavenLocalRepository) spec.buildTimeoutSeconds.orNull?.let(buildTimeoutSeconds::set) + spec.applyInputs(inputs) } wireTestTasks( @@ -279,6 +282,8 @@ abstract class SmokeTestAppExtension @Inject constructor( /** Common DSL for a smoke-test application artifact. */ abstract class ApplicationSpec @Inject constructor() { + private val inputActions = mutableListOf>() + init { additionalSystemProperties.convention(emptyMap()) } @@ -298,6 +303,15 @@ abstract class ApplicationSpec @Inject constructor() { * than the single primary artifact path (e.g. a separately unpacked server install). */ abstract val additionalSystemProperties: MapProperty + + /** Configures additional inputs of the application build task. */ + fun inputs(action: Action) { + inputActions.add(action) + } + + internal fun applyInputs(inputs: TaskInputs) { + inputActions.forEach { it.execute(inputs) } + } } /** DSL describing a nested Gradle invocation for one smoke-test application. */ diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt index 74dfe30dcd4..6288d332978 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt @@ -124,8 +124,17 @@ class SmokeTestAppEndToEndTest { } @ParameterizedTest(name = "{0}") - @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory", "GraalVM release"]) + @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory"]) fun `tests track nested application contents`(scenario: String) { + assertTestsTrackApplicationContents(scenario) + } + + @Test + fun `tests track explicitly declared native toolchain inputs`() { + assertTestsTrackApplicationContents("GraalVM release") + } + + private fun assertTestsTrackApplicationContents(scenario: String) { val maven = scenario == "Maven artifact" val graalvm = scenario == "GraalVM release" val extra = scenario.startsWith("extra") @@ -188,9 +197,22 @@ class SmokeTestAppEndToEndTest { sysProperty = "sample.path", additionalConfig = when { - graalvm -> "environment.put(\"GRAALVM_HOME\", layout.projectDirectory.dir(\"graalvm\").asFile.absolutePath)" - extra -> "additionalSystemProperties.put(\"sample.extra\", \"$extraPath\")" - else -> "" + graalvm -> { + """ + environment.put("GRAALVM_HOME", layout.projectDirectory.dir("graalvm").asFile.absolutePath) + inputs { + property("graalvmRelease", providers.fileContents(layout.projectDirectory.file("graalvm/release")).asText) + } + """.trimIndent() + } + + extra -> { + "additionalSystemProperties.put(\"sample.extra\", \"$extraPath\")" + } + + else -> { + "" + } }, ) } @@ -346,8 +368,6 @@ class SmokeTestAppEndToEndTest { fun `nested build receives native app environment and provider backed file inputs`() { writeOuterSettings() val graalvmHome = projectDir.resolve("test-graalvm").toFile() - graalvmHome.mkdirs() - File(graalvmHome, "release").writeText("GRAALVM_VERSION=25.0.1") File(projectDir.toFile(), "agent.jar").writeText("agent") writeSmokeTestAppBuild( """ @@ -395,7 +415,7 @@ class SmokeTestAppEndToEndTest { } @Test - fun `unselected and skipped native builds do not resolve GraalVM home`() { + fun `application inputs are only queried for selected builds that run`() { writeOuterSettings() writeSmokeTestAppBuild( smokeTestGradleApplication( @@ -403,7 +423,9 @@ class SmokeTestAppEndToEndTest { artifactPath = "native-app", sysProperty = "native.path", additionalConfig = """ - environment.put("GRAALVM_HOME", providers.provider { error("GraalVM home queried") }) + inputs { + property("externalTool", providers.provider { error("Tool metadata queried") }) + } """, ), extraImports = "import datadog.buildlogic.smoketest.NestedGradleBuild", @@ -419,57 +441,121 @@ class SmokeTestAppEndToEndTest { outerBuild.writeText( outerBuild .readText() - .replace("error(\"GraalVM home queried\")", "layout.projectDirectory.dir(\"missing-graalvm\").asFile.absolutePath"), + .replace("providers.provider { error(\"Tool metadata queried\") }", "providers.gradleProperty(\"missingRequired\")"), ) val skipped = runner("nativeBuild", "--configuration-cache").build() assertThat(skipped.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SKIPPED) outerBuild.writeText(outerBuild.readText().replace("onlyIf { false }", "onlyIf { true }")) - val missingRelease = runner("nativeBuild").buildAndFail() - assertThat(missingRelease.output).contains("missing-graalvm", "release") + val missingInput = runner("nativeBuild").buildAndFail() + assertThat(missingInput.output).contains("externalTool", "value") } - @Test - fun `GraalVM release changes remain visible with the configuration cache`() { - writeOuterSettings() - val release = projectDir.resolve("graalvm/release").toFile() + @ParameterizedTest(name = "{0}") + @ValueSource(strings = ["Gradle Kotlin", "Maven Kotlin", "Gradle Groovy", "Maven Groovy"]) + fun `explicit application inputs participate in caching`(scenario: String) { + val maven = scenario.startsWith("Maven") + val groovy = scenario.endsWith("Groovy") + val application = if (maven) "mavenApp" else "gradleApp" + val taskType = if (maven) "NestedMavenBuild" else "NestedGradleBuild" + writeOuterSettings(withLocalBuildCache = true) + val release = projectDir.resolve("toolchain/release").toFile() release.parentFile.mkdirs() release.writeText("first") - writeSmokeTestAppBuild( - smokeTestGradleApplication( - taskName = "nativeBuild", - artifactPath = "native-app.txt", - sysProperty = "native.path", - additionalConfig = "environment.put(\"GRAALVM_HOME\", layout.projectDirectory.dir(\"graalvm\").asFile.absolutePath)", - ), - extraImports = "import datadog.buildlogic.smoketest.NestedGradleBuild", - extraPreamble = """ - tasks.withType().configureEach { - actions.clear() - val metadata = graalvmRelease - val target = applicationBuildDir.file("native-app.txt") - doLast { - target.get().asFile.apply { - parentFile.mkdirs() - writeText(metadata.get()) + if (maven) { + writeFakeMavenWrapper() + } + if (groovy) { + File(projectDir.toFile(), "build.gradle").writeText( + """ + import datadog.buildlogic.smoketest.$taskType + + plugins { + id 'java' + id 'dd-trace-java.smoke-test-app' + } + + def metadata = providers.fileContents(layout.projectDirectory.file('toolchain/release')).asText + smokeTestApp { + $application { + taskName = 'packageApp' + artifactPath = 'toolchain.txt' + sysProperty = 'toolchain.path' + ${if (maven) "mavenExecutable.set(layout.projectDirectory.file('${fakeMavenWrapperName()}'))" else ""} + inputs { + property('toolchainRelease', metadata) + property('optionalIdentity', providers.gradleProperty('optionalIdentity')).optional(true) + } + inputs { property('anotherInput', 'fixed') } } } - } - """, - ) - val first = runner("nativeBuild", "--configuration-cache").build() - assertThat(first.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(applicationOutput("native-app.txt")).content().isEqualTo("first") + tasks.named('packageApp', $taskType) { + actions.clear() + def target = applicationBuildDir.file('toolchain.txt') + doLast { + def output = target.get().asFile + output.parentFile.mkdirs() + output.setText(metadata.get(), 'UTF-8') + } + } + """.trimIndent(), + ) + } else { + val config = + """ + ${if (maven) "mavenExecutable.set(layout.projectDirectory.file(\"${fakeMavenWrapperName()}\"))" else ""} + inputs { + property("toolchainRelease", metadata) + property("optionalIdentity", providers.gradleProperty("optionalIdentity")).optional(true) + } + inputs { property("anotherInput", "fixed") } + """.trimIndent() + writeSmokeTestAppBuild( + if (maven) { + smokeTestMavenApplication("packageApp", "toolchain.txt", "toolchain.path", config) + } else { + smokeTestGradleApplication("packageApp", "toolchain.txt", "toolchain.path", config) + }, + extraImports = "import datadog.buildlogic.smoketest.$taskType", + extraPreamble = """ + val metadata = providers.fileContents(layout.projectDirectory.file("toolchain/release")).asText + tasks.withType<$taskType>().configureEach { + actions.clear() + val metadataInput = metadata + val target = applicationBuildDir.file("toolchain.txt") + doLast { + target.get().asFile.apply { + parentFile.mkdirs() + writeText(metadataInput.get()) + } + } + } + """, + ) + } + + val first = runner("packageApp", "--build-cache", "--configuration-cache").build() + assertThat(first.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(applicationOutput("toolchain.txt")).content().isEqualTo("first") - val unchanged = runner("nativeBuild", "--configuration-cache").build() + val unchanged = runner("packageApp", "--build-cache", "--configuration-cache").build() assertThat(unchanged.output).contains("Reusing configuration cache") - assertThat(unchanged.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) + assertThat(unchanged.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.UP_TO_DATE) + + applicationBuildDir.deleteRecursively() + val restored = runner("packageApp", "--build-cache", "--configuration-cache").build() + assertThat(restored.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.FROM_CACHE) + assertThat(applicationOutput("toolchain.txt")).content().isEqualTo("first") release.writeText("second") - val changed = runner("nativeBuild", "--configuration-cache").build() - assertThat(changed.task(":nativeBuild")?.outcome).isEqualTo(TaskOutcome.SUCCESS) - assertThat(applicationOutput("native-app.txt")).content().isEqualTo("second") + val changed = runner("packageApp", "--build-cache", "--configuration-cache").build() + assertThat(changed.task(":packageApp")?.outcome).isEqualTo(TaskOutcome.SUCCESS) + assertThat(applicationOutput("toolchain.txt")).content().isEqualTo("second") + + release.delete() + val missing = runner("packageApp", "--build-cache", "--configuration-cache").buildAndFail() + assertThat(missing.output).contains("toolchainRelease", "value") } @Test diff --git a/dd-smoke-tests/quarkus-native/build.gradle b/dd-smoke-tests/quarkus-native/build.gradle index 271050d6898..2b52cac55bc 100644 --- a/dd-smoke-tests/quarkus-native/build.gradle +++ b/dd-smoke-tests/quarkus-native/build.gradle @@ -31,6 +31,9 @@ Provider graalvmHome = providers.provider({ } getLazyJavaHomeFor(testGraalvmVersion, true).toString() } as Callable) +Provider graalvmRelease = providers.fileContents( + layout.file(graalvmHome.map { home -> new File(home, 'release') }) + ).asText smokeTestApp { gradleApp { @@ -44,6 +47,9 @@ smokeTestApp { '-Dquarkus.package.jar.enabled=false', ) environment.put('GRAALVM_HOME', graalvmHome) + inputs { + property('graalvmRelease', graalvmRelease) + } } projectJar('apiJar', project(':dd-trace-api')) projectJar('agentJar', project(':dd-java-agent'), 'shadow') diff --git a/dd-smoke-tests/spring-boot-3.0-native/build.gradle b/dd-smoke-tests/spring-boot-3.0-native/build.gradle index 595ca3f0012..fd40eed6fdc 100644 --- a/dd-smoke-tests/spring-boot-3.0-native/build.gradle +++ b/dd-smoke-tests/spring-boot-3.0-native/build.gradle @@ -31,6 +31,9 @@ Provider graalvmHome = providers.provider({ } getLazyJavaHomeFor(testGraalvmVersion, true).toString() } as Callable) +Provider graalvmRelease = providers.fileContents( + layout.file(graalvmHome.map { home -> new File(home, 'release') }) + ).asText smokeTestApp { gradleApp { @@ -42,6 +45,9 @@ smokeTestApp { environment.put('GRAALVM_HOME', graalvmHome) environment.put('DD_TRACE_METHODS', 'datadog.smoketest.springboot.controller.WebController[sayHello]') environment.put('NATIVE_IMAGE_DEPRECATED_BUILDER_SANITATION', 'true') + inputs { + property('graalvmRelease', graalvmRelease) + } } projectJar('apiJar', project(':dd-trace-api')) projectJar('agentPath', project(':dd-java-agent'), 'shadow') From a4b628d928ccb12dd147bff037dba071a7210beb Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Wed, 28 Oct 2026 11:37:49 +0100 Subject: [PATCH 4/5] feat: provide lazy file contents in smoke builds --- .../buildlogic/smoketest/SmokeTestAppExtension.kt | 12 ++++++++++++ .../buildlogic/smoketest/SmokeTestAppEndToEndTest.kt | 10 +++++++--- dd-smoke-tests/quarkus-native/build.gradle | 5 +---- dd-smoke-tests/spring-boot-3.0-native/build.gradle | 5 +---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt index a5e09d5f153..da6a7c1f0a3 100644 --- a/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt +++ b/build-logic/smoke-test/src/main/kotlin/datadog/buildlogic/smoketest/SmokeTestAppExtension.kt @@ -85,6 +85,18 @@ abstract class SmokeTestAppExtension @Inject constructor( } + /** Provides lazy text contents relative to a directory provider. */ + fun fileContent( + directory: Provider, + relativePath: String, + ): Provider = + project.providers + .fileContents( + project.layout.projectDirectory + .dir(directory) + .map { it.file(relativePath) }, + ).asText + /** * Register the nested-build task and wire the produced artifact into every `Test` task as * an input and a system property. Calling this triggers task registration; consumers that prefer to diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt index 6288d332978..4d2a83c4f7b 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt @@ -424,7 +424,7 @@ class SmokeTestAppEndToEndTest { sysProperty = "native.path", additionalConfig = """ inputs { - property("externalTool", providers.provider { error("Tool metadata queried") }) + property("externalTool", fileContent(providers.provider { error("Tool metadata queried") }, "release")) } """, ), @@ -476,6 +476,7 @@ class SmokeTestAppEndToEndTest { } def metadata = providers.fileContents(layout.projectDirectory.file('toolchain/release')).asText + def home = providers.provider { 'toolchain' } smokeTestApp { $application { taskName = 'packageApp' @@ -483,8 +484,9 @@ class SmokeTestAppEndToEndTest { sysProperty = 'toolchain.path' ${if (maven) "mavenExecutable.set(layout.projectDirectory.file('${fakeMavenWrapperName()}'))" else ""} inputs { - property('toolchainRelease', metadata) + property('toolchainRelease', fileContent(home, 'release')) property('optionalIdentity', providers.gradleProperty('optionalIdentity')).optional(true) + property('optionalFileContent', fileContent(providers.gradleProperty('missingHome'), 'release')).optional(true) } inputs { property('anotherInput', 'fixed') } } @@ -506,8 +508,9 @@ class SmokeTestAppEndToEndTest { """ ${if (maven) "mavenExecutable.set(layout.projectDirectory.file(\"${fakeMavenWrapperName()}\"))" else ""} inputs { - property("toolchainRelease", metadata) + property("toolchainRelease", fileContent(home, "release")) property("optionalIdentity", providers.gradleProperty("optionalIdentity")).optional(true) + property("optionalFileContent", fileContent(providers.gradleProperty("missingHome"), "release")).optional(true) } inputs { property("anotherInput", "fixed") } """.trimIndent() @@ -520,6 +523,7 @@ class SmokeTestAppEndToEndTest { extraImports = "import datadog.buildlogic.smoketest.$taskType", extraPreamble = """ val metadata = providers.fileContents(layout.projectDirectory.file("toolchain/release")).asText + val home = providers.provider { "${release.parentFile.invariantSeparatorsPath}" } tasks.withType<$taskType>().configureEach { actions.clear() val metadataInput = metadata diff --git a/dd-smoke-tests/quarkus-native/build.gradle b/dd-smoke-tests/quarkus-native/build.gradle index 2b52cac55bc..dc2c747fd46 100644 --- a/dd-smoke-tests/quarkus-native/build.gradle +++ b/dd-smoke-tests/quarkus-native/build.gradle @@ -31,9 +31,6 @@ Provider graalvmHome = providers.provider({ } getLazyJavaHomeFor(testGraalvmVersion, true).toString() } as Callable) -Provider graalvmRelease = providers.fileContents( - layout.file(graalvmHome.map { home -> new File(home, 'release') }) - ).asText smokeTestApp { gradleApp { @@ -48,7 +45,7 @@ smokeTestApp { ) environment.put('GRAALVM_HOME', graalvmHome) inputs { - property('graalvmRelease', graalvmRelease) + property('graalvmRelease', fileContent(graalvmHome, 'release')) } } projectJar('apiJar', project(':dd-trace-api')) diff --git a/dd-smoke-tests/spring-boot-3.0-native/build.gradle b/dd-smoke-tests/spring-boot-3.0-native/build.gradle index fd40eed6fdc..2424d22e6ad 100644 --- a/dd-smoke-tests/spring-boot-3.0-native/build.gradle +++ b/dd-smoke-tests/spring-boot-3.0-native/build.gradle @@ -31,9 +31,6 @@ Provider graalvmHome = providers.provider({ } getLazyJavaHomeFor(testGraalvmVersion, true).toString() } as Callable) -Provider graalvmRelease = providers.fileContents( - layout.file(graalvmHome.map { home -> new File(home, 'release') }) - ).asText smokeTestApp { gradleApp { @@ -46,7 +43,7 @@ smokeTestApp { environment.put('DD_TRACE_METHODS', 'datadog.smoketest.springboot.controller.WebController[sayHello]') environment.put('NATIVE_IMAGE_DEPRECATED_BUILDER_SANITATION', 'true') inputs { - property('graalvmRelease', graalvmRelease) + property('graalvmRelease', fileContent(graalvmHome, 'release')) } } projectJar('apiJar', project(':dd-trace-api')) From 4f1ca88d55191cde512ba01add6dc1f71e844e5b Mon Sep 17 00:00:00 2001 From: Brice Dutheil Date: Thu, 29 Oct 2026 13:15:41 +0100 Subject: [PATCH 5/5] test: use TableTest for smoke build scenarios --- build-logic/smoke-test/build.gradle.kts | 1 + .../smoketest/SmokeTestAppEndToEndTest.kt | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/build-logic/smoke-test/build.gradle.kts b/build-logic/smoke-test/build.gradle.kts index 11b470b31e6..6791a342bde 100644 --- a/build-logic/smoke-test/build.gradle.kts +++ b/build-logic/smoke-test/build.gradle.kts @@ -39,6 +39,7 @@ testing { implementation(libs.junit.jupiter) implementation(libs.junit.jupiter.params) implementation(libs.junit.jupiter.engine) + implementation(libs.tabletest) implementation(libs.assertj.core) implementation(gradleTestKit()) } diff --git a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt index 4d2a83c4f7b..01ae03fe534 100644 --- a/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt +++ b/build-logic/smoke-test/src/test/kotlin/datadog/buildlogic/smoketest/SmokeTestAppEndToEndTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource -import org.junit.jupiter.params.provider.ValueSource +import org.tabletest.junit.TableTest import java.io.File import java.nio.file.Files import java.nio.file.Path @@ -123,10 +123,15 @@ class SmokeTestAppEndToEndTest { assertThat(applicationOutput("target/sample.jar")).exists() } - @ParameterizedTest(name = "{0}") - @ValueSource(strings = ["Gradle artifact", "Maven artifact", "extra file", "extra directory"]) - fun `tests track nested application contents`(scenario: String) { - assertTestsTrackApplicationContents(scenario) + @TableTest( + "scenario | input ", + "Gradle artifact | Gradle artifact", + "Maven artifact | Maven artifact ", + "extra file | extra file ", + "extra directory | extra directory", + ) + fun `tests track nested application contents`(input: String) { + assertTestsTrackApplicationContents(input) } @Test @@ -451,11 +456,17 @@ class SmokeTestAppEndToEndTest { assertThat(missingInput.output).contains("externalTool", "value") } - @ParameterizedTest(name = "{0}") - @ValueSource(strings = ["Gradle Kotlin", "Maven Kotlin", "Gradle Groovy", "Maven Groovy"]) - fun `explicit application inputs participate in caching`(scenario: String) { - val maven = scenario.startsWith("Maven") - val groovy = scenario.endsWith("Groovy") + @TableTest( + "scenario | maven | groovy", + "Gradle Kotlin | false | false ", + "Maven Kotlin | true | false ", + "Gradle Groovy | false | true ", + "Maven Groovy | true | true ", + ) + fun `explicit application inputs participate in caching`( + maven: Boolean, + groovy: Boolean, + ) { val application = if (maven) "mavenApp" else "gradleApp" val taskType = if (maven) "NestedMavenBuild" else "NestedGradleBuild" writeOuterSettings(withLocalBuildCache = true)