diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a6de275bfd9..c5063a6b190 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -471,6 +471,7 @@ test_published_artifacts: check_build_src: extends: .check_job + needs: [] variables: GRADLE_TARGET: ":buildSrc:build" diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 3a13ed61339..ad06adecc27 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -78,6 +78,7 @@ dependencies { implementation("org.eclipse.aether", "aether-connector-basic", "1.1.0") implementation("org.eclipse.aether", "aether-transport-http", "1.1.0") + implementation("org.eclipse.aether", "aether-transport-file", "1.1.0") implementation("org.apache.maven", "maven-aether-provider", "3.3.9") implementation("com.github.zafarkhaja:java-semver:0.10.2") @@ -103,9 +104,12 @@ testing { @Suppress("UnstableApiUsage") suites { val test by getting(JvmTestSuite::class) { + dependencies { + implementation(libs.assertj.core) + } targets.configureEach { testTask.configure { - enabled = project.hasProperty("runBuildSrcTests") + enabled = providers.systemProperty("runBuildSrcTests").isPresent or providers.systemProperty("idea.active").isPresent } } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt index 13e8752cb27..998e0357b18 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtils.kt @@ -12,6 +12,7 @@ import org.eclipse.aether.resolution.VersionRangeRequest import org.eclipse.aether.resolution.VersionRangeResult import org.eclipse.aether.spi.connector.RepositoryConnectorFactory import org.eclipse.aether.spi.connector.transport.TransporterFactory +import org.eclipse.aether.transport.file.FileTransporterFactory import org.eclipse.aether.transport.http.HttpTransporterFactory import org.eclipse.aether.version.Version import org.gradle.api.GradleException @@ -34,13 +35,15 @@ internal object MuzzleMavenRepoUtils { } /** - * Create new RepositorySystem for muzzle's Maven/Aether resoltions. + * Create new RepositorySystem for muzzle's Maven/Aether resolutions. + * Supports both HTTP/HTTPS and file:// repositories. */ @JvmStatic fun newRepositorySystem(): RepositorySystem { val locator = MavenRepositorySystemUtils.newServiceLocator().apply { addService(RepositoryConnectorFactory::class.java, BasicRepositoryConnectorFactory::class.java) addService(TransporterFactory::class.java, HttpTransporterFactory::class.java) + addService(TransporterFactory::class.java, FileTransporterFactory::class.java) } return locator.getService(RepositorySystem::class.java) } @@ -66,7 +69,8 @@ internal object MuzzleMavenRepoUtils { fun inverseOf( muzzleDirective: MuzzleDirective, system: RepositorySystem, - session: RepositorySystemSession + session: RepositorySystemSession, + defaultRepos: List = MUZZLE_REPOS ): Set { val allVersionsArtifact = DefaultArtifact( muzzleDirective.group, @@ -74,7 +78,7 @@ internal object MuzzleMavenRepoUtils { "jar", "[,)" ) - val repos = muzzleDirective.getRepositories(MUZZLE_REPOS) + val repos = muzzleDirective.getRepositories(defaultRepos) val allRangeRequest = VersionRangeRequest().apply { repositories = repos artifact = allVersionsArtifact @@ -119,7 +123,8 @@ internal object MuzzleMavenRepoUtils { fun resolveVersionRange( muzzleDirective: MuzzleDirective, system: RepositorySystem, - session: RepositorySystemSession + session: RepositorySystemSession, + defaultRepos: List = MUZZLE_REPOS ): VersionRangeResult { val directiveArtifact: Artifact = DefaultArtifact( muzzleDirective.group, @@ -129,7 +134,7 @@ internal object MuzzleMavenRepoUtils { muzzleDirective.versions ) val rangeRequest = VersionRangeRequest().apply { - repositories = muzzleDirective.getRepositories(MUZZLE_REPOS) + repositories = muzzleDirective.getRepositories(defaultRepos) artifact = directiveArtifact } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt index ae15cc58fcb..876c2234218 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -1,13 +1,11 @@ package datadog.gradle.plugin.muzzle -import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils.inverseOf -import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts -import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils.resolveVersionRange import datadog.gradle.plugin.muzzle.tasks.MuzzleEndTask import datadog.gradle.plugin.muzzle.tasks.MuzzleGenerateReportTask import datadog.gradle.plugin.muzzle.tasks.MuzzleGetReferencesTask import datadog.gradle.plugin.muzzle.tasks.MuzzleMergeReportsTask import datadog.gradle.plugin.muzzle.tasks.MuzzleTask +import datadog.gradle.plugin.muzzle.planner.MuzzleTaskPlanner import org.eclipse.aether.artifact.Artifact import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin @@ -101,14 +99,15 @@ class MuzzlePlugin : Plugin { project.tasks.register("mergeMuzzleReports") val hasRelevantTask = project.gradle.startParameter.taskNames.any { taskName -> - // removing leading ':' if present - val muzzleTaskName = taskName.removePrefix(":") - val projectPath = project.path.removePrefix(":") - muzzleTaskName == "muzzle" || "$projectPath:muzzle" == muzzleTaskName || - muzzleTaskName == "runMuzzle" + val taskProjectPath = taskName.substringBeforeLast(":", "") + val taskNameOnly = taskName.substringAfterLast(":") + val isRelevantForProject = taskProjectPath.isEmpty() || taskProjectPath == project.path + + isRelevantForProject && taskNameOnly.endsWith("muzzle", ignoreCase = true) } if (!hasRelevantTask) { // Adding muzzle dependencies has a large config overhead. Stop unless muzzle is explicitly run. + project.logger.info("No muzzle tasks invoked for ${project.path}, skipping muzzle task planification") return } @@ -117,40 +116,19 @@ class MuzzlePlugin : Plugin { val system = MuzzleMavenRepoUtils.newRepositorySystem() val session = MuzzleMavenRepoUtils.newRepositorySystemSession(system) + val taskPlanner = MuzzleTaskPlanner.from(system, session) project.afterEvaluate { // use runAfter to set up task finalizers in version order var runAfter: TaskProvider = muzzleTask val muzzleReportTasks = mutableListOf>() - - project.extensions.getByType().directives.forEach { directive -> - project.logger.debug("configuring {}", directive) - - if (directive.isCoreJdk) { - runAfter = addMuzzleTask(directive, null, project, runAfter, muzzleBootstrap, muzzleTooling) - muzzleReportTasks.add(runAfter) - } else { - val range = resolveVersionRange(directive, system, session) - - muzzleDirectiveToArtifacts(directive, range).forEach { - runAfter = addMuzzleTask(directive, it, project, runAfter, muzzleBootstrap, muzzleTooling) - muzzleReportTasks.add(runAfter) - } - - if (directive.assertInverse) { - inverseOf(directive, system, session).forEach { inverseDirective -> - val inverseRange = resolveVersionRange(inverseDirective, system, session) - - muzzleDirectiveToArtifacts(inverseDirective, inverseRange).forEach { - runAfter = addMuzzleTask(inverseDirective, it, project, runAfter, muzzleBootstrap, muzzleTooling) - muzzleReportTasks.add(runAfter) - } - } - } - } - project.logger.info("configured $directive") + val directives = project.extensions.getByType().directives + taskPlanner.plan(directives).forEach { plan -> + runAfter = registerMuzzleTask(plan.directive, plan.artifact, project, runAfter, muzzleBootstrap, muzzleTooling) + muzzleReportTasks.add(runAfter) + project.logger.info("configured ${plan.directive}") } - if (muzzleReportTasks.isEmpty() && !project.extensions.getByType().directives.any { it.assertPass }) { + if (muzzleReportTasks.isEmpty() && !directives.any { it.assertPass }) { muzzleReportTasks.add(muzzleTask) } @@ -180,7 +158,7 @@ class MuzzlePlugin : Plugin { * @param muzzleTooling The configuration provider for agent tooling dependencies. * @return The muzzle task provider. */ - private fun addMuzzleTask( + private fun registerMuzzleTask( muzzleDirective: MuzzleDirective, versionArtifact: Artifact?, instrumentationProject: Project, diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt index f5d61653e17..9a12471775b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtils.kt @@ -68,7 +68,7 @@ internal object MuzzleVersionUtils { /** * Select a random set of versions to test */ - private val RANGE_COUNT_LIMIT = 25 + internal val RANGE_COUNT_LIMIT = 25 /** * Select a random set of versions to test, limiting the range for efficiency. diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt new file mode 100644 index 00000000000..ee9d7c38369 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MavenMuzzleResolutionService.kt @@ -0,0 +1,23 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.muzzle.MuzzleDirective +import datadog.gradle.plugin.muzzle.MuzzleMavenRepoUtils +import org.eclipse.aether.RepositorySystem +import org.eclipse.aether.RepositorySystemSession +import org.eclipse.aether.artifact.Artifact + +/** + * Default [MuzzleResolutionService] implementation backed by Maven/Aether resolution. + */ +internal class MavenMuzzleResolutionService( + private val system: RepositorySystem, + private val session: RepositorySystemSession, +) : MuzzleResolutionService { + override fun resolveArtifacts(directive: MuzzleDirective): Set { + val range = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, session) + return MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts(directive, range) + } + + override fun inverseOf(directive: MuzzleDirective): Set = + MuzzleMavenRepoUtils.inverseOf(directive, system, session) +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleResolutionService.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleResolutionService.kt new file mode 100644 index 00000000000..bcdd81427e9 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleResolutionService.kt @@ -0,0 +1,19 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.muzzle.MuzzleDirective +import org.eclipse.aether.artifact.Artifact + +/** + * Resolves muzzle directives into concrete artifacts and inverse directives. + */ +internal interface MuzzleResolutionService { + /** + * Resolves all dependency artifacts to test for the given directive. + */ + fun resolveArtifacts(directive: MuzzleDirective): Set + + /** + * Computes directives representing the inverse of the given directive. + */ + fun inverseOf(directive: MuzzleDirective): Set +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlan.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlan.kt new file mode 100644 index 00000000000..6b3a3dbd5f0 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlan.kt @@ -0,0 +1,14 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.muzzle.MuzzleDirective +import org.eclipse.aether.artifact.Artifact + +/** + * Planned unit of muzzle work for task creation. + * + * For `coreJdk()` directives, [artifact] is `null`. + */ +internal data class MuzzleTaskPlan( + val directive: MuzzleDirective, + val artifact: Artifact?, +) diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlanner.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlanner.kt new file mode 100644 index 00000000000..107fbf2f2d9 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlanner.kt @@ -0,0 +1,55 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.muzzle.MuzzleDirective +import org.eclipse.aether.RepositorySystem +import org.eclipse.aether.RepositorySystemSession + +/** + * Expands configured directives into ordered task plans. + */ +internal class MuzzleTaskPlanner( + private val resolutionService: MuzzleResolutionService, +) { + companion object { + fun from(system: RepositorySystem, session: RepositorySystemSession): MuzzleTaskPlanner = + MuzzleTaskPlanner(MavenMuzzleResolutionService(system, session)) + } + + /** + * Expands declared muzzle directives into executable task plans. + * + * Planning rules: + * - Core-JDK directives (`coreJdk()`) create exactly one [MuzzleTaskPlan] with `artifact = null`. + * - Non-core directives are resolved with [MuzzleResolutionService.resolveArtifacts], creating one + * plan per resolved artifact. + * - If a non-core directive has `assertInverse = true`, inverse directives are obtained from + * [MuzzleResolutionService.inverseOf], then each inverse directive is resolved and expanded with + * the same one-plan-per-artifact rule. + * + * Ordering: + * - The input [directives] order is preserved. + * - Direct plans for a directive are emitted before its inverse plans. + * - Artifact plan order follows the iteration order returned by the resolution service. + * + * No de-duplication is performed here. If needed, de-duplication must be handled by callers or by + * the resolution service implementation. + */ + fun plan(directives: List): List = buildList { + directives.forEach { directive -> + if (directive.isCoreJdk) { + add(MuzzleTaskPlan(directive, null)) + } else { + resolutionService.resolveArtifacts(directive).forEach { artifact -> + add(MuzzleTaskPlan(directive, artifact)) + } + if (directive.assertInverse) { + resolutionService.inverseOf(directive).forEach { inverseDirective -> + resolutionService.resolveArtifacts(inverseDirective).forEach { artifact -> + add(MuzzleTaskPlan(inverseDirective, artifact)) + } + } + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt index ec3929ba619..2d5d830ea3b 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleTask.kt @@ -74,13 +74,17 @@ abstract class MuzzleTask @Inject constructor( @TaskAction fun muzzle() { when { + // Version-specific task: created by MuzzlePlugin for each resolved artifact. + muzzleDirective.isPresent -> { + assertMuzzle(muzzleDirective.get()) + } + // Fallback for the root "muzzle" lifecycle task when no pass{} directives are + // declared. In that case there are no version-specific pass tasks, so we assert + // the instrumentation against its own compile-time classpath as a basic sanity check. !project.extensions.getByType().directives.any { it.assertPass } -> { project.logger.info("No muzzle pass directives configured. Asserting pass against instrumentation compile-time dependencies") assertMuzzle() } - muzzleDirective.isPresent -> { - assertMuzzle(muzzleDirective.get()) - } } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/GradleFixture.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/GradleFixture.kt new file mode 100644 index 00000000000..3bb1f85f2d5 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/GradleFixture.kt @@ -0,0 +1,82 @@ +package datadog.gradle.plugin + +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.UnexpectedBuildResultException +import org.intellij.lang.annotations.Language +import org.w3c.dom.Document +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * Base fixture for Gradle plugin integration tests. + * Provides common functionality for setting up test projects and running Gradle builds. + */ +internal open class GradleFixture(protected val projectDir: File) { + /** + * Runs Gradle with the specified arguments. + * + * @param args Gradle task names and arguments + * @param expectFailure Whether the build is expected to fail + * @param env Environment variables to set (merged with system environment) + * @return The build result + */ + fun run(vararg args: String, expectFailure: Boolean = false, env: Map = emptyMap()): BuildResult { + val runner = GradleRunner.create() + .withTestKitDir(File(projectDir, ".gradle-test-kit")) + .withPluginClasspath() + .withProjectDir(projectDir) + .withEnvironment(System.getenv() + env) + .withArguments(*args) + return try { + if (expectFailure) runner.buildAndFail() else runner.build() + } catch (e: UnexpectedBuildResultException) { + e.buildResult + } + } + + /** + * Adds a subproject to the build. + * Updates settings.gradle and creates the build script for the subproject. + * + * @param projectPath The project path (e.g., "dd-java-agent:instrumentation:other") + * @param buildScript The build script content for the subproject + */ + fun addSubproject(projectPath: String, @Language("Groovy") buildScript: String) { + // Add to settings.gradle + val settingsFile = file("settings.gradle") + if (settingsFile.exists()) { + settingsFile.appendText("\ninclude ':$projectPath'") + } else { + settingsFile.writeText("include ':$projectPath'") + } + + file("${projectPath.replace(':', '/')}/build.gradle") + .writeText(buildScript.trimIndent()) + } + + /** + * Writes the root project's build.gradle file. + * + * @param buildScript The build script content for the root project + */ + fun writeRootProject(@Language("Groovy") buildScript: String) { + file("build.gradle").writeText(buildScript.trimIndent()) + } + + /** + * Parses an XML file into a DOM Document. + */ + fun parseXml(xmlFile: File): Document { + val builder = DocumentBuilderFactory.newInstance().newDocumentBuilder() + return builder.parse(xmlFile) + } + + /** + * Creates or gets a file in the project directory, ensuring parent directories exist. + */ + protected fun file(path: String): File = + File(projectDir, path).also { file -> + file.parentFile?.mkdirs() + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt new file mode 100644 index 00000000000..02216f1c7fb --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/MavenRepoFixture.kt @@ -0,0 +1,191 @@ +package datadog.gradle.plugin + +import java.io.File +import java.nio.file.Path +import java.security.MessageDigest +import java.util.jar.JarOutputStream + +/** + * Test fixture for creating and managing fake Maven repositories. + * Provides utilities to create Maven artifacts with proper structure and metadata. + * + * The fake Maven repository is automatically created in the constructor. + */ +class MavenRepoFixture(projectDir: File) { + + /** The root directory of the fake Maven repository */ + val repoDir: File = File(projectDir, "fake-maven-repo").apply { mkdirs() } + + /** + * Gets the repository URL for use in Gradle configuration. + */ + val repoUrl: String + get() = repoDir.toURI().toString() + + /** + * Publishes versions to the fake Maven repository for the specified module. + * If the module already exists, adds the new versions to the existing ones. + * Creates the module directory if it doesn't exist. + * + * @param group Maven group ID + * @param module Maven artifact ID + * @param versions List of versions to publish (will be merged with existing versions) + * @param jarContentBuilder Optional lambda to add entries to the JAR + */ + fun publishVersions( + group: String, + module: String, + versions: List, + jarContentBuilder: ((JarOutputStream) -> Unit)? = null + ) { + require(versions.isNotEmpty()) { "versions must not be empty" } + val groupPath = group.replace('.', '/') + val moduleDir = File(repoDir, "$groupPath/$module").apply { mkdirs() } + + // Create all version artifacts + versions.forEach { version -> + createMavenVersion(moduleDir, group, module, version, jarContentBuilder) + } + + // Read existing versions from metadata and merge with new versions + val metadataFile = File(moduleDir, "maven-metadata.xml") + val existingVersions = if (metadataFile.exists()) { + val content = metadataFile.readText() + val versionRegex = "([^<]+)".toRegex() + versionRegex.findAll(content).map { it.groupValues[1] }.toList() + } else { + emptyList() + } + + // Merge and sort all versions + val allVersions = (existingVersions + versions).distinct().sorted() + writeMavenMetadata(metadataFile, group, module, allVersions) + } + + /** + * Creates a single Maven version with POM and JAR artifacts (including checksums). + * + * @param moduleDir The module directory (e.g., repo/com/example/artifact) + * @param group Maven group ID + * @param module Maven artifact ID + * @param version Version to create + * @param jarContentBuilder Optional lambda to add entries to the JAR + */ + private fun createMavenVersion( + moduleDir: File, + group: String, + module: String, + version: String, + jarContentBuilder: ((JarOutputStream) -> Unit)? = null + ) { + val versionDir = File(moduleDir, version).apply { mkdirs() } + + val pomFile = File(versionDir, "$module-$version.pom") + pomFile.writeText( + """ + + 4.0.0 + $group + $module + $version + jar + + """.trimIndent() + ) + writeChecksum(pomFile) + + // Create JAR file + val jarFile = File(versionDir, "$module-$version.jar") + createJar(jarFile.toPath(), group, module, version, jarContentBuilder) + writeChecksum(jarFile) + } + + /** + * Writes maven-metadata.xml for a module with the given versions. + * + * @param metadataFile The maven-metadata.xml file to write + * @param group Maven group ID + * @param module Maven artifact ID + * @param versions List of versions (should be sorted) + */ + private fun writeMavenMetadata( + metadataFile: File, + group: String, + module: String, + versions: List + ) { + metadataFile.writeText( + """ + + $group + $module + + ${versions.last()} + ${versions.last()} + + ${versions.joinToString("\n") { " $it" }} + + ${System.currentTimeMillis() / 1000} + + + """.trimIndent() + ) + writeChecksum(metadataFile) + } + + /** + * Generates SHA-1 and MD5 checksum files for a given file. + */ + private fun writeChecksum(file: File) { + val content = file.readBytes() + val sha1 = MessageDigest.getInstance("SHA-1").digest(content) + .joinToString("") { "%02x".format(it) } + File(file.parentFile, "${file.name}.sha1").writeText(sha1) + + val md5 = MessageDigest.getInstance("MD5").digest(content) + .joinToString("") { "%02x".format(it) } + File(file.parentFile, "${file.name}.md5").writeText(md5) + } + + /** + * Creates a JAR file at the specified path with standard Maven metadata, optionally with custom content. + * + * @param path Path where the JAR should be created + * @param group Maven group ID + * @param module Maven artifact ID + * @param version Maven version + * @param contentBuilder Optional lambda to add custom entries to the JAR + */ + private fun createJar( + path: Path, + group: String, + module: String, + version: String, + contentBuilder: ((JarOutputStream) -> Unit)? = null + ) { + JarOutputStream(path.toFile().outputStream()).use { jos -> + // Add Maven metadata + val pomProperties = """ + groupId=$group + artifactId=$module + version=$version + """.trimIndent() + + val pomPropertiesPath = "META-INF/maven/$group/$module/pom.properties" + jos.putNextEntry(java.util.jar.JarEntry(pomPropertiesPath)) + jos.write(pomProperties.toByteArray()) + jos.closeEntry() + + // Add custom content if provided + contentBuilder?.invoke(jos) + + // Add manifest if not provided by contentBuilder + val manifestEntry = java.util.jar.JarEntry("META-INF/MANIFEST.MF") + jos.putNextEntry(manifestEntry) + jos.write("Manifest-Version: 1.0\n".toByteArray()) + jos.closeEntry() + } + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt new file mode 100644 index 00000000000..b5c2ccaef42 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleDirectiveTest.kt @@ -0,0 +1,162 @@ +package datadog.gradle.plugin.muzzle + +import org.eclipse.aether.repository.RemoteRepository +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.assertj.core.api.Assertions.assertThat + +class MuzzleDirectiveTest { + + @ParameterizedTest(name = "[{index}] nameSlug(''{0}'') == ''{1}''") + @CsvSource( + value = + [ + "simple, simple", + "My Directive, My-Directive", + "foo/bar@baz#123, foo-bar-baz-123", + ]) + fun `nameSlug replaces non-alphanumeric characters with dashes`(input: String, expected: String) { + val directive = MuzzleDirective().apply { name = input } + assertThat(directive.nameSlug).isEqualTo(expected.trim()) + } + + @Test + fun `nameSlug returns empty string for empty name`() { + val directive = MuzzleDirective().apply { name = "" } + assertThat(directive.nameSlug).isEmpty() + } + + @Test + fun `nameSlug trims leading and trailing whitespace before replacing`() { + val directive = MuzzleDirective().apply { name = " spaces " } + assertThat(directive.nameSlug).isEqualTo("spaces") + } + + @Test + fun `nameSlug returns empty string when name is null`() { + val directive = MuzzleDirective() // name defaults to null + assertThat(directive.nameSlug).isEmpty() + } + + @Test + fun `getRepositories returns defaults unchanged when no additional repos`() { + val directive = MuzzleDirective() + val defaults = listOf(RemoteRepository.Builder("central", "default", "https://repo1.maven.org/maven2/").build()) + + val repos = directive.getRepositories(defaults) + + // Same reference — no copy is made when additionalRepositories is empty + assertThat(repos).isSameAs(defaults) + } + + @Test + fun `getRepositories appends additional repositories after defaults`() { + val directive = + MuzzleDirective().apply { + extraRepository("myrepo", "https://example.com/repo") + extraRepository("otherrepo", "https://other.example.com/repo", "default") + } + val defaults = + listOf( + RemoteRepository.Builder("central", "default", "https://repo1.maven.org/maven2/").build()) + + val repos = directive.getRepositories(defaults) + + assertThat(repos.map { it.id }).containsExactly("central", "myrepo", "otherrepo") + } + + @Test + fun `coreJdk without version sets isCoreJdk true and javaVersion null`() { + val directive = MuzzleDirective() + directive.coreJdk() + + assertThat(directive.isCoreJdk).isTrue() + assertThat(directive.javaVersion).isNull() + } + + @Test + fun `coreJdk with version sets isCoreJdk true and javaVersion`() { + val directive = MuzzleDirective() + directive.coreJdk("17") + + assertThat(directive.isCoreJdk).isTrue() + assertThat(directive.javaVersion).isEqualTo("17") + } + + @ParameterizedTest(name = "[{index}] coreJdk={0}, assertPass={1} → {2}") + @CsvSource( + value = + [ + "true, true, Pass-core-jdk", + "true, false, Fail-core-jdk", + ]) + fun `toString for coreJdk directive`(isCoreJdk: Boolean, assertPass: Boolean, expected: String) { + val directive = + MuzzleDirective().apply { + if (isCoreJdk) coreJdk() + this.assertPass = assertPass + } + assertThat(directive.toString()).isEqualTo(expected) + } + + @ParameterizedTest(name = "[{index}] assertPass={0} → prefix ''{1}''") + @CsvSource( + value = + [ + "true, pass", + "false, fail", + ]) + fun `toString for non-coreJdk directive includes group module versions`( + assertPass: Boolean, + prefix: String + ) { + val directive = + MuzzleDirective().apply { + group = "com.example" + module = "mylib" + versions = "[1.0,2.0)" + this.assertPass = assertPass + } + + assertThat(directive.toString()).isEqualTo("$prefix com.example:mylib:[1.0,2.0)") + } + + @Test + fun `extraDependency accumulates multiple entries in order`() { + val directive = MuzzleDirective() + directive.extraDependency("com.example:dep1:1.0") + directive.extraDependency("com.example:dep2:2.0") + directive.extraDependency("com.example:dep3:3.0") + + assertThat(directive.additionalDependencies).containsExactly( + "com.example:dep1:1.0", + "com.example:dep2:2.0", + "com.example:dep3:3.0" + ) + } + + @Test + fun `excludeDependency accumulates multiple entries in order`() { + val directive = MuzzleDirective() + directive.excludeDependency("com.example:excluded1") + directive.excludeDependency("com.example:excluded2") + + assertThat(directive.excludedDependencies).containsExactly( + "com.example:excluded1", + "com.example:excluded2" + ) + } + + @Test + fun `extraRepository accumulates multiple entries in order`() { + val directive = MuzzleDirective() + directive.extraRepository("repo1", "https://repo1.example.com") + directive.extraRepository("repo2", "https://repo2.example.com", "p2") + + assertThat(directive.additionalRepositories).containsExactly( + Triple("repo1", "default", "https://repo1.example.com"), + Triple("repo2", "p2", "https://repo2.example.com"), + ) + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt new file mode 100644 index 00000000000..8c500a42f43 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleMavenRepoUtilsTest.kt @@ -0,0 +1,227 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.MavenRepoFixture +import org.eclipse.aether.artifact.DefaultArtifact +import org.eclipse.aether.repository.RemoteRepository +import org.eclipse.aether.resolution.VersionRangeRequest +import org.eclipse.aether.resolution.VersionRangeResult +import org.eclipse.aether.util.version.GenericVersionScheme +import org.gradle.api.GradleException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import java.io.File +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy + +class MuzzleMavenRepoUtilsTest { + + @TempDir + lateinit var tempDir: File + + private val system = MuzzleMavenRepoUtils.newRepositorySystem() + + private val versionScheme = GenericVersionScheme() + + @Test + fun `resolveVersionRange resolves all versions matching an open range`() { + val repo = publishAndGetRepo("com.example", "mylib", listOf("1.0.0", "2.0.0", "3.0.0")) + val directive = MuzzleDirective().apply { + group = "com.example" + module = "mylib" + versions = "[1.0,)" + } + + val result = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, newSession(), listOf(repo)) + + val resolvedVersions = result.versions.map { it.toString() } + assertThat(resolvedVersions).containsExactly("1.0.0", "2.0.0", "3.0.0") + } + + @Test + fun `resolveVersionRange respects bounded version range`() { + val repo = publishAndGetRepo("com.example", "mylib", listOf("1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0")) + val directive = MuzzleDirective().apply { + group = "com.example" + module = "mylib" + versions = "[2.0,4.0)" + } + + val result = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, newSession(), listOf(repo)) + + val resolvedVersions = result.versions.map { it.toString() } + assertThat(resolvedVersions).containsExactly("2.0.0", "3.0.0") + } + + @Test + fun `resolveVersionRange throws IllegalStateException when resolution consistently fails`() { + val emptyRepo = RemoteRepository.Builder("empty", "default", File(tempDir, "empty").apply { mkdirs() }.toURI().toString()).build() + val directive = MuzzleDirective().apply { + group = "com.example" + module = "nonexistent" + versions = "[1.0,)" + } + + assertThatThrownBy { + MuzzleMavenRepoUtils.resolveVersionRange(directive, system, newSession(), listOf(emptyRepo)) + }.isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `resolveVersionRange includes directive extra repositories`() { + val repoA = publishAndGetRepo("com.example", "mylib", listOf("1.0.0", "2.0.0"), subDir = "repoA") + val fixtureB = MavenRepoFixture(File(tempDir, "repoB")) + fixtureB.publishVersions("com.example", "mylib", listOf("3.0.0")) + val directive = MuzzleDirective().apply { + group = "com.example" + module = "mylib" + versions = "[1.0,)" + extraRepository("repoB", fixtureB.repoUrl) + } + + val result = MuzzleMavenRepoUtils.resolveVersionRange(directive, system, newSession(), listOf(repoA)) + + val resolvedVersions = result.versions.map { it.toString() } + assertThat(resolvedVersions) + .withFailMessage("Expected all 3 versions from both repos, got: $resolvedVersions") + .containsAll(listOf("1.0.0", "2.0.0", "3.0.0")) + } + + @Test + fun `inverseOf returns directives outside range, inverts assertPass, and preserves properties`() { + val repo = publishAndGetRepo("com.example", "mylib", listOf("1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0")) + val directive = MuzzleDirective().apply { + name = "mytest" + group = "com.example" + module = "mylib" + versions = "[2.0,4.0)" + assertPass = true + excludeDependency("com.other:dep") + includeSnapshots = false + } + + val result = MuzzleMavenRepoUtils.inverseOf(directive, system, newSession(), listOf(repo)) + + val resultVersions = result.map { it.versions }.toSet() + // Versions inside [2.0, 4.0) are 2.0.0 and 3.0.0 — they should NOT appear + assertThat(resultVersions).doesNotContain("2.0.0", "3.0.0") + // Versions outside range: 1.0.0, 4.0.0, 5.0.0 + assertThat(resultVersions).contains("1.0.0", "4.0.0", "5.0.0") + + // assertPass must be inverted, and directive properties must be preserved + assertThat(result).allSatisfy { directive -> + assertThat(directive.assertPass).isFalse() + assertThat(directive.name).isEqualTo("mytest") + assertThat(directive.group).isEqualTo("com.example") + assertThat(directive.module).isEqualTo("mylib") + assertThat(directive.excludedDependencies).containsExactly("com.other:dep") + assertThat(directive.includeSnapshots).isFalse() + } + } + + @ParameterizedTest(name = "[{index}] highest({0}, {1}) == {2}") + @CsvSource( + value = + [ + "1.0.0, 2.0.0, 2.0.0", + "2.0.0, 1.0.0, 2.0.0", + "3.5.1, 3.5.1, 3.5.1", // equal — either is acceptable + ]) + fun `highest returns the greater version`(a: String, b: String, expected: String) { + val result = MuzzleMavenRepoUtils.highest(version(a), version(b)) + assertThat(result).isEqualTo(version(expected)) + } + + @ParameterizedTest(name = "[{index}] lowest({0}, {1}) == {2}") + @CsvSource( + value = + [ + "1.0.0, 2.0.0, 1.0.0", + "2.0.0, 1.0.0, 1.0.0", + "3.5.1, 3.5.1, 3.5.1", // equal — either is acceptable + ]) + fun `lowest returns the lesser version`(a: String, b: String, expected: String) { + val result = MuzzleMavenRepoUtils.lowest(version(a), version(b)) + assertThat(result).isEqualTo(version(expected)) + } + + @Test + fun `muzzleDirectiveToArtifacts throws GradleException when all versions are filtered out`() { + val directive = + MuzzleDirective().apply { + group = "com.example" + module = "test" + includeSnapshots = false // SNAPSHOT and RC will be filtered + } + // All versions are pre-release; none survive filterAndLimitVersions + val rangeResult = createVersionRangeResult("1.0.0-SNAPSHOT", "2.0.0-RC1") + + assertThatThrownBy { + MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts(directive, rangeResult) + }.isInstanceOf(GradleException::class.java) + } + + @Test + fun `muzzleDirectiveToArtifacts produces artifacts with correct coordinates`() { + val directive = + MuzzleDirective().apply { + group = "com.example" + module = "mylib" + // classifier is null → DefaultArtifact receives "" + includeSnapshots = false + } + // Distinct major.minor versions so lowAndHighForMajorMinor keeps all three + val rangeResult = createVersionRangeResult("1.0.0", "2.0.0", "3.0.0") + + val artifacts = MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts(directive, rangeResult) + + assertThat(artifacts).hasSize(3) + assertThat(artifacts).allSatisfy { artifact -> + assertThat(artifact.groupId).isEqualTo("com.example") + assertThat(artifact.artifactId).isEqualTo("mylib") + assertThat(artifact.extension).isEqualTo("jar") + assertThat(artifact.classifier).isEmpty() + } + assertThat(artifacts.map { it.version }).containsOnly("1.0.0", "2.0.0", "3.0.0") + } + + @Test + fun `muzzleDirectiveToArtifacts propagates classifier to artifacts`() { + val directive = + MuzzleDirective().apply { + group = "com.example" + module = "mylib" + classifier = "tests" + includeSnapshots = false + } + val rangeResult = createVersionRangeResult("1.0.0", "2.0.0") + + val artifacts = MuzzleMavenRepoUtils.muzzleDirectiveToArtifacts(directive, rangeResult) + + assertThat(artifacts).allSatisfy { assertThat(it.classifier).isEqualTo("tests") } + } + + private fun newSession() = MuzzleMavenRepoUtils.newRepositorySystemSession(system) + + private fun publishAndGetRepo( + group: String, + module: String, + versions: List, + subDir: String = "default" + ): RemoteRepository { + val fixture = MavenRepoFixture(File(tempDir, subDir)) + fixture.publishVersions(group, module, versions) + return RemoteRepository.Builder(subDir, "default", fixture.repoUrl).build() + } + + private fun version(v: String) = versionScheme.parseVersion(v) + + private fun createVersionRangeResult(vararg versionStrings: String): VersionRangeResult { + val artifact = DefaultArtifact("com.example:test:[1.0,)") + val request = VersionRangeRequest(artifact, emptyList(), null) + val versions = versionStrings.map { versionScheme.parseVersion(it) }.sorted() + // lowestVersion/highestVersion are computed as versions[0] and versions[last] + return VersionRangeResult(request).apply { this.versions = versions } + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt new file mode 100644 index 00000000000..00eb2514db0 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt @@ -0,0 +1,869 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.GradleFixture +import datadog.gradle.plugin.MavenRepoFixture +import org.assertj.core.api.Assertions.assertThat +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import java.io.File +import kotlin.io.path.readText + +class MuzzlePluginFunctionalTest { + @ParameterizedTest + @ValueSource(strings = ["muzzle", ":dd-java-agent:instrumentation:demo:muzzle", "runMuzzle"]) + fun `detects muzzle invocation with various task names`( + taskName: String, + @TempDir projectDir: File + ) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + coreJdk() + } + } + """ + ) + + // Add runMuzzle aggregator task at root level (like in dd-trace-java.ci-jobs.gradle.kts) + fixture.writeRootProject( + """ + tasks.register('runMuzzle') { + dependsOn(':dd-java-agent:instrumentation:demo:muzzle') + } + """ + ) + + fixture.writeNoopScanPlugin() + + val result = fixture.run(taskName, "--stacktrace") + + assertThat(result.tasks) + .withFailMessage("Should create muzzle tasks when '$taskName' is requested") + .anyMatch { it.path.contains("muzzle") } + assertThat(result.output) + .withFailMessage("Should not skip muzzle task planification when '$taskName' is requested") + .doesNotContain("No muzzle tasks invoked, skipping muzzle task planification") + assertThat(result.tasks).withFailMessage("Should execute muzzle tasks when '$taskName' is requested") + .anyMatch { it.path == ":dd-java-agent:instrumentation:demo:muzzle" || it.path.contains("muzzle-Assert") } + } + + @Test + fun `muzzle with pass directive writes junit report`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + name = 'expected-pass' + coreJdk() + } + } + """ + ) + fixture.writeScanPlugin( + """ + if (!assertPass) { + throw new IllegalStateException("unexpected fail assertion for " + muzzleDirective); + } + """ + ) + + val buildResult = fixture.run(":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace") + assertThat(buildResult.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(buildResult.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(buildResult.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome).isEqualTo(SUCCESS) + + val reportFile = fixture.findSingleMuzzleJUnitReport() + val report = fixture.parseXml(reportFile) + val suite = report.documentElement + assertThat(suite.tagName).isEqualTo("testsuite") + assertThat(suite.getAttribute("name")).isEqualTo(":dd-java-agent:instrumentation:demo") + assertThat(suite.getAttribute("tests")).isEqualTo("1") + assertThat(suite.getAttribute("failures")).isEqualTo("0") + + val passCase = findTestCase(report, "muzzle-AssertPass-core-jdk") + assertThat(passCase.getElementsByTagName("failure").length).isEqualTo(0) + } + + @Test + fun `muzzle without directives writes default junit report`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + """ + ) + fixture.writeScanPlugin( + """ + if (!assertPass) { + throw new IllegalStateException("unexpected fail assertion for " + muzzleDirective); + } + """ + ) + + val result = fixture.run(":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + + val reportFile = fixture.findSingleMuzzleJUnitReport() + val report = fixture.parseXml(reportFile) + val suite = report.documentElement + assertThat(suite.getAttribute("name")).isEqualTo(":dd-java-agent:instrumentation:demo") + assertThat(suite.getAttribute("tests")).isEqualTo("1") + assertThat(suite.getAttribute("failures")).isEqualTo("0") + + val defaultCase = findTestCase(report, "muzzle") + assertThat(defaultCase.getElementsByTagName("failure").length).isEqualTo(0) + } + + @Test + fun `non muzzle invocation does not register muzzle end task`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + coreJdk() + } + } + """ + ) + + val buildResult = fixture.run(":dd-java-agent:instrumentation:demo:tasks", "--all") + + assertThat(buildResult.output).doesNotContain("muzzle-end") + } + + @Test + fun `muzzle plugin wires bootstrap and tooling project classpaths`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + """ + ) + + val bootstrapDependencies = fixture.run( + ":dd-java-agent:instrumentation:demo:dependencies", + "--configuration", + "muzzleBootstrap" + ) + assertThat(bootstrapDependencies.output).contains("project :dd-java-agent:agent-bootstrap") + + val toolingDependencies = fixture.run( + ":dd-java-agent:instrumentation:demo:dependencies", + "--configuration", + "muzzleTooling" + ) + assertThat(toolingDependencies.output).contains("project :dd-java-agent:agent-tooling") + } + + @Test + fun `muzzle executes exactly planned core-jdk tasks and writes task results`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { coreJdk() } + fail { coreJdk() } + } + """ + ) + fixture.writeScanPlugin( + """ + // pass + """ + ) + + val result = fixture.run(":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace") + val muzzleTaskPath = ":dd-java-agent:instrumentation:demo:muzzle" + val passDirectiveTaskPath = ":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk" + val failDirectiveTaskPath = ":dd-java-agent:instrumentation:demo:muzzle-AssertFail-core-jdk" + val endTaskPath = ":dd-java-agent:instrumentation:demo:muzzle-end" + + assertThat(result.task(muzzleTaskPath)?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(passDirectiveTaskPath)?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(failDirectiveTaskPath)?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(endTaskPath)?.outcome).isEqualTo(SUCCESS) + + val muzzleChainInOrder = result.tasks + .map { it.path } + .filter { + it == muzzleTaskPath || + it == passDirectiveTaskPath || + it == failDirectiveTaskPath || + it == endTaskPath + } + assertThat(muzzleChainInOrder) + .containsExactly(muzzleTaskPath, passDirectiveTaskPath, failDirectiveTaskPath, endTaskPath) + + val passDirectiveResult = fixture.resultFile("muzzle-AssertPass-core-jdk") + val failDirectiveResult = fixture.resultFile("muzzle-AssertFail-core-jdk") + assertThat(passDirectiveResult).isRegularFile() + assertThat(failDirectiveResult).isRegularFile() + assertThat(passDirectiveResult.readText()).isEqualTo("PASSING") + assertThat(failDirectiveResult.readText()).isEqualTo("PASSING") + } + + @Test + fun `artifact directive resolves multiple versions from version range`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + val mavenRepoFixture = MavenRepoFixture(projectDir) + + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "demo-lib", + versions = listOf("1.0.0", "1.1.0", "1.2.0", "2.0.0") + ) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + // Gradle repositories for artifact download + repositories { + maven { + url = uri('${mavenRepoFixture.repoUrl}') + metadataSources { + mavenPom() + artifact() + // Disable checksum validation for fake repo + } + } + } + + muzzle { + pass { + group = 'com.example.test' + module = 'demo-lib' + versions = '[1.0.0,2.0.0)' // Should resolve 1.0.0, 1.1.0, 1.2.0 but NOT 2.0.0 + } + } + """ + ) + fixture.writeNoopScanPlugin() + + // Leveraging MAVEN_REPOSITORY_PROXY to point to our fake repo over maven central + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(result.output) + .withFailMessage("Build should succeed. Output:\n${result.output.take(3000)}") + .contains("BUILD SUCCESSFUL") + + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome).isEqualTo(SUCCESS) + + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-demo-lib-1.0.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-demo-lib-1.1.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-demo-lib-1.2.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-demo-lib-2.0.0")?.outcome) + .withFailMessage("Should not check against test-demo-lib:2.0.0") + .isNull() + + val reportFile = fixture.findSingleMuzzleJUnitReport() + val report = fixture.parseXml(reportFile) + val suite = report.documentElement + val testCount = suite.getAttribute("tests").toInt() + assertThat(testCount) + .withFailMessage("Should have at least 3 tests for 3 versions, got $testCount") + .isGreaterThanOrEqualTo(3) + assertThat(suite.getAttribute("failures")).withFailMessage("Should have no failures").isEqualTo("0") + + val testCases = (0 until report.getElementsByTagName("testcase").length) + .map { report.getElementsByTagName("testcase").item(it) as org.w3c.dom.Element } + .map { it.getAttribute("name") } + assertThat(testCases).withFailMessage("Should have test case for demo-lib-1.0.0. Found: ${testCases.take(5)}") + .anySatisfy { assertThat(it).contains("demo-lib-1.0.0") } + } + + @Test + fun `named directive is passed to scan plugin`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + name = 'my-custom-check' + coreJdk() + } + } + """ + ) + + // The real MuzzleVersionScanPlugin uses the directive name to filter InstrumenterModules + fixture.writeScanPlugin( + """ + if (!"my-custom-check".equals(muzzleDirective)) { + throw new IllegalStateException( + "Expected muzzleDirective to be 'my-custom-check', but got: '" + muzzleDirective + "'" + ); + } + + System.out.println("Directive name passed correctly: " + muzzleDirective); + """ + ) + + val result = fixture.run(":dd-java-agent:instrumentation:demo:muzzle", "--stacktrace") + + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.output).withFailMessage("Should confirm 'my-custom-check' was passed to scan plugin") + .contains("Directive name passed correctly: my-custom-check") + } + + @Test + fun `non-existent artifact fails with clear error message`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + group = 'com.example.nonexistent' + module = 'does-not-exist' + versions = '[1.0.0,2.0.0)' + } + } + """ + ) + fixture.writeNoopScanPlugin() + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to "https://repo1.maven.org/maven2/") + ) + + assertThat(result.output).withFailMessage("Build should fail for non-existent artifact").contains("BUILD FAILED") + assertThat(result.output).withFailMessage("Should have error message about resolution failure") + .containsAnyOf( + "version range resolution failed", + "Could not resolve", + "not found", + "Failed to resolve" + ) + } + + @Test + fun `pass directive that fails validation causes build failure`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + coreJdk() + } + } + """ + ) + + // Real implementation throws RuntimeException when !passed && assertPass (line 70 of MuzzleVersionScanPlugin) + fixture.writeScanPlugin( + """ + if (assertPass) { + System.err.println("FAILED MUZZLE VALIDATION: mismatches:"); + System.err.println("-- missing class Foo"); + throw new RuntimeException("Instrumentation failed Muzzle validation"); + } + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace" + ) + + assertThat(result.output).withFailMessage("Build should fail when pass directive fails validation") + .contains("BUILD FAILED") + assertThat(result.output).withFailMessage("Should contain error message from scan plugin") + .containsAnyOf("Muzzle validation failed", "Instrumentation failed") + } + + @Test + fun `fail directive that passes validation causes build failure`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + fail { + coreJdk() + } + } + """ + ) + + // Scan plugin simulates successful validation when it should fail + // Real MuzzleVersionScanPlugin throws RuntimeException when passed && !assertPass + fixture.writeScanPlugin( + """ + if (!assertPass) { + System.err.println("MUZZLE PASSED BUT FAILURE WAS EXPECTED"); + throw new RuntimeException("Instrumentation unexpectedly passed Muzzle validation"); + } + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace" + ) + + // Expected behavior: build should fail when fail directive unexpectedly passes + assertThat(result.output) + .withFailMessage("Build should fail when fail directive unexpectedly passes") + .contains("BUILD FAILED") + assertThat(result.output).withFailMessage("Should indicate that fail directive passed when it shouldn't have") + .containsAnyOf("unexpectedly passed", "FAILURE WAS EXPECTED") + } + + @Test + fun `additional dependencies are added to muzzle test classpath`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + val mavenRepoFixture = MavenRepoFixture(projectDir) + + // Create a fake Maven repo with a fake additional dependency + // The JAR will automatically include standard Maven metadata + mavenRepoFixture.publishVersions( + group = "com.example.extra", + module = "extra-lib", + versions = listOf("1.0.0") + ) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + repositories { + maven { + url = uri('${mavenRepoFixture.repoUrl}') + metadataSources { + mavenPom() + artifact() + } + } + } + + muzzle { + pass { + coreJdk() + extraDependency('com.example.extra:extra-lib:1.0.0') + } + } + """ + ) + + // Scan plugin verifies that the additional dependency JAR is in the classpath + fixture.writeScanPlugin( + """ + java.io.InputStream resource = testApplicationClassLoader.getResourceAsStream("META-INF/maven/com.example.extra/extra-lib/pom.properties"); + if (resource != null) { + try { + resource.close(); + } catch (java.io.IOException e) { + // Ignore + } + System.out.println("Additional dependency (extra-lib) found in test classpath"); + } else { + throw new RuntimeException("Additional dependency (extra-lib) not found in test classpath"); + } + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(result.output) + .withFailMessage("Build should succeed. Output:\n${result.output.take(2000)}") + .contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.output).withFailMessage("Additional dependency should be loadable from test classpath") + .contains("Additional dependency (extra-lib) found in test classpath") + } + + @Test + fun `excluded dependencies are removed from muzzle test classpath`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + val mavenRepoFixture = MavenRepoFixture(projectDir) + + // Create a fake repo with an artifact that has transitive dependencies + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "with-transitive", + versions = listOf("1.0.0") + ) + + // Manually create a POM with a transitive dependency + val pomFile = mavenRepoFixture.repoDir.resolve("com/example/test/with-transitive/1.0.0/with-transitive-1.0.0.pom") + pomFile.writeText( + """ + + 4.0.0 + com.example.test + with-transitive + 1.0.0 + + + com.google.guava + guava + 31.0-jre + + + + """.trimIndent() + ) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + repositories { + maven { + url = uri('${mavenRepoFixture.repoUrl}') + metadataSources { + mavenPom() + artifact() + } + } + mavenCentral() + } + + muzzle { + pass { + group = 'com.example.test' + module = 'with-transitive' + versions = '1.0.0' + excludeDependency('com.google.guava:guava') + } + } + """ + ) + + // Scan plugin verifies that guava is NOT in the classpath (it was excluded) + fixture.writeScanPlugin( + """ + try { + testApplicationClassLoader.loadClass("com.google.common.collect.ImmutableList"); + throw new RuntimeException("Unexpected excluded dependency (guava) SHOULD NOT be in test classpath but was found"); + } catch (ClassNotFoundException e) { + System.out.println("Excluded dependency (guava) correctly not in test classpath"); + } + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-with-transitive-1.0.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.output).withFailMessage("Excluded dependency should not be loadable from test classpath") + .contains("Excluded dependency (guava) correctly not in test classpath") + } + + @Test + fun `java plugin applied after muzzle plugin`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'dd-trace-java.muzzle' + } + + // applied after muzzle plugin + apply plugin: 'java' + + muzzle { + pass { + coreJdk() + } + } + """ + ) + fixture.writeNoopScanPlugin() + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace" + ) + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome).isEqualTo(SUCCESS) + } + + @Test + fun `java plugin applied before muzzle plugin`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' apply false // Declared but not applied + } + + // Apply muzzle plugin after java using imperative syntax + apply plugin: 'dd-trace-java.muzzle' + + muzzle { + pass { + coreJdk() + } + } + """ + ) + fixture.writeNoopScanPlugin() + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace" + ) + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome).isEqualTo(SUCCESS) + } + + @Test + fun `plugin behavior without java plugin should no-op`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'dd-trace-java.muzzle' + // NO java plugin applied + } + + muzzle { + pass { + coreJdk() + } + } + """ + ) + fixture.writeNoopScanPlugin() + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:tasks", + "--all" + ) + + assertThat(result.output) + .withFailMessage("Should not create muzzle tasks without java plugin") + .doesNotContain("muzzle") + } + + @Test + fun `missing dd-java-agent projects error handling`(@TempDir projectDir: File) { + // Create a minimal settings.gradle without the dd-java-agent structure + File(projectDir, "settings.gradle").also { it.parentFile?.mkdirs() }.writeText( + """ + rootProject.name = 'muzzle-test' + include ':instrumentation:demo' + """.trimIndent() + ) + + File(projectDir, "instrumentation/demo/build.gradle").also { it.parentFile?.mkdirs() }.writeText( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + coreJdk() + } + } + """.trimIndent() + ) + + // No need to create MuzzleVersionScanPlugin - the error happens during configuration + // phase before any task execution, so the scan plugin is never invoked + + val result = GradleFixture(projectDir).run( + ":instrumentation:demo:tasks", + "--stacktrace" + ) + + assertThat(result.output).withFailMessage("Should fail with clear error about missing dd-java-agent projects") + .containsAnyOf( + "BUILD FAILED", + ":dd-java-agent:agent-bootstrap project not found", + ":dd-java-agent:agent-tooling project not found" + ) + } + + @Test + fun `assertInverse creates pass and fail tasks for in-range and out-of-range versions`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + val mavenRepoFixture = MavenRepoFixture(projectDir) + + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "inverse-lib", + versions = listOf("1.0.0", "2.0.0", "3.0.0", "4.0.0") + ) + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + // Gradle repositories for artifact download + repositories { + maven { + url = uri('${mavenRepoFixture.repoUrl}') + metadataSources { + mavenPom() + artifact() + } + } + } + + muzzle { + pass { + group = 'com.example.test' + module = 'inverse-lib' + versions = '[2.0.0,3.0.0]' + assertInverse = true + } + } + """ + ) + fixture.writeScanPlugin( + """ + System.out.println("MUZZLE_CHECK assertPass=" + assertPass); + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(result.output) + .withFailMessage("Build should succeed. Output:\n${result.output.take(3000)}") + .contains("BUILD SUCCESSFUL") + + val modulePrefix = ":dd-java-agent:instrumentation:demo" + assertThat(result.task("$modulePrefix:muzzle")?.outcome).isEqualTo(SUCCESS) + assertThat(result.task("$modulePrefix:muzzle-end")?.outcome).isEqualTo(SUCCESS) + + // In-range versions — assertPass=true + assertThat(result.task("$modulePrefix:muzzle-AssertPass-com.example.test-inverse-lib-2.0.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.task("$modulePrefix:muzzle-AssertPass-com.example.test-inverse-lib-3.0.0")?.outcome) + .isEqualTo(SUCCESS) + + // Out-of-range versions (inverse) — assertPass=false + assertThat(result.task("$modulePrefix:muzzle-AssertFail-com.example.test-inverse-lib-1.0.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.task("$modulePrefix:muzzle-AssertFail-com.example.test-inverse-lib-4.0.0")?.outcome) + .isEqualTo(SUCCESS) + + assertThat(result.output) + .withFailMessage("Should log assertPass=true for in-range versions") + .contains("MUZZLE_CHECK assertPass=true") + assertThat(result.output) + .withFailMessage("Should log assertPass=false for out-of-range (inverse) versions") + .contains("MUZZLE_CHECK assertPass=false") + + // Verify JUnit report contains all 4 test cases with no failures + val reportFile = fixture.findSingleMuzzleJUnitReport() + val report = fixture.parseXml(reportFile) + val suite = report.documentElement + assertThat(suite.getAttribute("tests")) + .withFailMessage("Should have 4 test cases (2 pass + 2 inverse fail)") + .isEqualTo("4") + assertThat(suite.getAttribute("failures")).withFailMessage("Should have no failures").isEqualTo("0") + + findTestCase(report, "muzzle-AssertPass-com.example.test-inverse-lib-2.0.0") + findTestCase(report, "muzzle-AssertPass-com.example.test-inverse-lib-3.0.0") + findTestCase(report, "muzzle-AssertFail-com.example.test-inverse-lib-1.0.0") + findTestCase(report, "muzzle-AssertFail-com.example.test-inverse-lib-4.0.0") + } + + private fun findTestCase(document: org.w3c.dom.Document, name: String): org.w3c.dom.Element = + (0 until document.getElementsByTagName("testcase").length) + .map { document.getElementsByTagName("testcase").item(it) as org.w3c.dom.Element } + .first { it.getAttribute("name") == name } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt new file mode 100644 index 00000000000..79aaf409b7c --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt @@ -0,0 +1,407 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.MavenRepoFixture +import org.gradle.testkit.runner.TaskOutcome.SUCCESS +import org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import org.assertj.core.api.Assertions.assertThat + +class MuzzlePluginPerformanceTest { + + @Test + fun `task graph does not include muzzle tasks when not requested`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { + group = 'com.example.test' + module = 'some-lib' + versions = '[1.0.0,2.0.0)' + } + } + """ + ) + fixture.writeNoopScanPlugin() + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:tasks", + "--all", + "--info" + ) + + assertThat(result.task(":dd-java-agent:instrumentation:demo:tasks")?.outcome).isEqualTo(SUCCESS) + + assertThat(result.tasks) + .withFailMessage("Should not create or execute any muzzle tasks when not requested") + .noneMatch { it.path.contains("muzzle") } + assertThat(result.output) + .withFailMessage("Should log early return when muzzle not requested") + .contains("No muzzle tasks invoked for :dd-java-agent:instrumentation:demo, skipping muzzle task planification") + } + + @Test + fun `does not configure muzzle when other project muzzle task is requested`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + muzzle { + pass { coreJdk() } + } + """ + ) + fixture.writeNoopScanPlugin() + fixture.addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + muzzle { + pass { coreJdk() } + } + """ + ) + + val result = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + "--stacktrace", + "--info" + ) + + assertThat(result.tasks) + .withFailMessage("Should execute muzzle tasks for demo project") + .anyMatch { it.path.contains("demo") && it.path.contains("muzzle") } + assertThat(result.tasks) + .withFailMessage("Should NOT create or register execute muzzle tasks for other project") + .noneMatch { it.path.contains("other") && it.path.contains("muzzle") } + assertThat(result.output.lines()) + .withFailMessage("Other project should skip muzzle configuration when demo project's muzzle is requested") + .anyMatch { it.contains("No muzzle tasks invoked for :dd-java-agent:instrumentation:other, skipping muzzle task planification") } + } + + @Test + fun `muzzle tasks are up-to-date when nothing changes`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + val mavenRepoFixture = MavenRepoFixture(projectDir) + + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "example-lib", + versions = listOf("1.0.0", "1.1.0") + ) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + repositories { + maven { + url = uri('${mavenRepoFixture.repoUrl}') + metadataSources { + mavenPom() + artifact() + } + } + } + + muzzle { + pass { + group = 'com.example.test' + module = 'example-lib' + versions = '[1.0.0,2.0.0)' + } + } + """ + ) + fixture.writeNoopScanPlugin() + + // First run - should execute the tasks + run { + val firstRun = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.0.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.1.0")?.outcome) + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome) + .withFailMessage("First run should execute muzzle-end task") + .isEqualTo(SUCCESS) + } + + // Second run without changes - assertion tasks should be up-to-date + run { + val secondRun = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.0.0")?.outcome) + .withFailMessage("1.0.0 assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.1.0")?.outcome) + .withFailMessage("1.1.0 assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome) + .withFailMessage("First run should execute muzzle-end task") + .isEqualTo(SUCCESS) + } + + // Third run after adding new version - should NOT be up-to-date + // Add version 1.2.0 to the fake Maven repo + run { + mavenRepoFixture.publishVersions( + group = "com.example.test", + module = "example-lib", + versions = listOf("1.2.0") + ) + + val thirdRun = fixture.run( + ":dd-java-agent:instrumentation:demo:muzzle", + env = mapOf("MAVEN_REPOSITORY_PROXY" to mavenRepoFixture.repoUrl) + ) + + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(UP_TO_DATE) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.0.0")?.outcome) + .withFailMessage("1.0.0 assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.1.0")?.outcome) + .withFailMessage("1.1.0 assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-com.example.test-example-lib-1.2.0")?.outcome) + .withFailMessage("New 1.2.0 assertion task should be created and execute") + .isEqualTo(SUCCESS) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome) + .withFailMessage("First run should execute muzzle-end task") + .isEqualTo(SUCCESS) + } + } + + @Test + fun `muzzle tasks invalidated when instrumentation code changes`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { coreJdk() } + } + """ + ) + fixture.writeNoopScanPlugin() + + // First run - should execute the tasks + run { + val firstRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("First run should execute coreJdk assertion task") + .isEqualTo(SUCCESS) + } + + // Second run without changes - assertion tasks should be up-to-date + run { + val secondRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Second run should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + } + + // Third run after changing instrumentation code - should be invalidated + run { + val demoSourceDir = File(projectDir, "dd-java-agent/instrumentation/demo/src/main/java/com/example") + demoSourceDir.mkdirs() + File(demoSourceDir, "Demo.java").writeText( + """ + package com.example; + + public class Demo { + public void doSomething() {} + } + """.trimIndent() + ) + + val thirdRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Third run should execute after instrumentation code change") + .isEqualTo(SUCCESS) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be invalidated and re-execute") + .isEqualTo(SUCCESS) + } + } + + @Test + fun `muzzle tasks invalidated when tooling classpath changes`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { coreJdk() } + } + """ + ) + fixture.writeNoopScanPlugin() + + // First run - should execute the tasks + run { + val firstRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("First run should execute coreJdk assertion task") + .isEqualTo(SUCCESS) + } + + // Second run without changes - assertion tasks should be up-to-date + run { + val secondRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Second run should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + } + + // Third run after changing agent-tooling code - should be invalidated + run { + val toolingSourceDir = File(projectDir, "dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling") + toolingSourceDir.mkdirs() + File(toolingSourceDir, "Extra.java").writeText( + """ + package datadog.trace.agent.tooling; + + public class Extra { + public void extraMethod() {} + } + """.trimIndent() + ) + + val thirdRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Third run should execute after tooling classpath change") + .isEqualTo(SUCCESS) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be invalidated and re-execute") + .isEqualTo(SUCCESS) + } + } + + @Test + fun `muzzle tasks invalidated when bootstrap classpath changes`(@TempDir projectDir: File) { + val fixture = MuzzlePluginTestFixture(projectDir) + + fixture.writeProject( + """ + plugins { + id 'java' + id 'dd-trace-java.muzzle' + } + + muzzle { + pass { coreJdk() } + } + """ + ) + fixture.writeNoopScanPlugin() + + // First run - should execute the tasks + run { + val firstRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("First run should execute muzzle task") + .isEqualTo(SUCCESS) + assertThat(firstRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("First run should execute coreJdk assertion task") + .isEqualTo(SUCCESS) + } + + // Second run without changes - assertion tasks should be up-to-date + run { + val secondRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Second run should be up-to-date") + .isEqualTo(UP_TO_DATE) + assertThat(secondRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be up-to-date") + .isEqualTo(UP_TO_DATE) + } + + // Third run after changing agent-bootstrap code - should be invalidated + run { + val bootstrapSourceDir = File(projectDir, "dd-java-agent/agent-bootstrap/src/main/java/datadog/trace/bootstrap") + bootstrapSourceDir.mkdirs() + File(bootstrapSourceDir, "Helper.java").writeText( + """ + package datadog.trace.bootstrap; + + public class Helper { + public void help() {} + } + """.trimIndent() + ) + + val thirdRun = fixture.run(":dd-java-agent:instrumentation:demo:muzzle") + + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle")?.outcome) + .withFailMessage("Third run should execute after bootstrap classpath change") + .isEqualTo(SUCCESS) + assertThat(thirdRun.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("coreJdk assertion task should be invalidated and re-execute") + .isEqualTo(SUCCESS) + } + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginTestFixture.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginTestFixture.kt new file mode 100644 index 00000000000..026ef5b0d9d --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginTestFixture.kt @@ -0,0 +1,100 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.GradleFixture +import org.intellij.lang.annotations.Language +import java.io.File + +/** + * Test fixture for muzzle plugin integration tests. + * Extends GradleFixture with muzzle-specific functionality. + */ +internal class MuzzlePluginTestFixture(projectDir: File) : GradleFixture(projectDir) { + + /** + * Writes the basic Gradle project structure for muzzle testing. + * Creates a multi-project build with agent-bootstrap, agent-tooling, and instrumentation modules. + */ + fun writeProject(@Language("Groovy") instrumentationBuildScript: String) { + file("settings.gradle").writeText( + // language=Groovy + """ + rootProject.name = 'muzzle-e2e' + """.trimIndent() + ) + + addSubproject("dd-java-agent:agent-bootstrap", + """ + plugins { + id 'java' + } + + tasks.register('compileMain_java11Java') + """ + ) + + addSubproject("dd-java-agent:agent-tooling", + """ + plugins { + id 'java' + } + """ + ) + + addSubproject("dd-java-agent:instrumentation:demo", instrumentationBuildScript) + } + + /** + * Writes a muzzle scan plugin that always passes. + */ + fun writeNoopScanPlugin() { + writeScanPlugin("// noop") + } + + /** + * Writes a muzzle scan plugin with custom assertion logic. + * The plugin is written to the agent-tooling project where it belongs. + * + * @param assertionBody Java code to execute in the assertion method + */ + fun writeScanPlugin(@Language("JAVA") assertionBody: String) { + file("dd-java-agent/agent-tooling/src/main/java/datadog/trace/agent/tooling/muzzle/MuzzleVersionScanPlugin.java") + .writeText( + // language=JAVA + """ + package datadog.trace.agent.tooling.muzzle; + + public final class MuzzleVersionScanPlugin { + private MuzzleVersionScanPlugin() {} + + public static void assertInstrumentationMuzzled( + ClassLoader instrumentationClassLoader, + ClassLoader testApplicationClassLoader, + boolean assertPass, + String muzzleDirective) { + $assertionBody + } + } + """.trimIndent() + ) + } + + /** + * Finds the single JUnit XML report generated by muzzle tests. + * Throws if zero or multiple reports are found. + */ + fun findSingleMuzzleJUnitReport(): File { + val reports = projectDir.walkTopDown() + .filter { it.isFile && it.name.startsWith("TEST-muzzle-") && it.extension == "xml" } + .toList() + require(reports.size == 1) { + "Expected exactly one JUnit muzzle report, but found ${reports.size}" + } + return reports.single() + } + + /** + * Returns the path to a muzzle result file for the given task name. + */ + fun resultFile(taskName: String) = + projectDir.toPath().resolve("dd-java-agent/instrumentation/demo/build/reports/$taskName.txt") +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginUtilsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginUtilsTest.kt new file mode 100644 index 00000000000..3435a923d27 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginUtilsTest.kt @@ -0,0 +1,64 @@ +package datadog.gradle.plugin.muzzle + +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.kotlin.dsl.getByType +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.assertj.core.api.Assertions.assertThat + +class MuzzlePluginUtilsTest { + @Test + fun `pathSlug for root project is empty`() { + val root = ProjectBuilder.builder().withName("root").build() + assertThat(root.pathSlug).isEmpty() + } + + @ParameterizedTest(name = "[{index}] path ''{0}'' → slug ''{1}''") + @CsvSource( + value = + [ + "foo, foo", + "foo_bar_baz, foo_bar_baz", // underscores are preserved (only colons are replaced) + ]) + fun `pathSlug for single-level child project`(childName: String, expectedSlug: String) { + val root = ProjectBuilder.builder().withName("root").build() + val child = ProjectBuilder.builder().withParent(root).withName(childName.trim()).build() + assertThat(child.pathSlug).isEqualTo(expectedSlug.trim()) + } + + @Test + fun `pathSlug for deeply nested project replaces colons with underscores`() { + val root = ProjectBuilder.builder().withName("root").build() + val foo = ProjectBuilder.builder().withParent(root).withName("foo").build() + val bar = ProjectBuilder.builder().withParent(foo).withName("bar").build() + val baz = ProjectBuilder.builder().withParent(bar).withName("baz").build() + + assertThat(baz.pathSlug).isEqualTo("foo_bar_baz") + } + + @Test + fun `allMainSourceSet includes main and excludes test`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("java") + + val sourceSets = project.allMainSourceSet + + assertThat(sourceSets.map { it.name }).contains("main").doesNotContain("test") + } + + @Test + fun `allMainSourceSet includes all source sets whose name starts with main`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("java") + project.extensions.getByType().apply { + create("mainLegacy") + create("mainJava8") + } + + val sourceSets = project.allMainSourceSet + + assertThat(sourceSets.map { it.name }).containsExactlyInAnyOrder("main", "mainLegacy", "mainJava8") + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt new file mode 100644 index 00000000000..9bc298a40f5 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleVersionUtilsTest.kt @@ -0,0 +1,156 @@ +package datadog.gradle.plugin.muzzle + +import datadog.gradle.plugin.muzzle.MuzzleVersionUtils.RANGE_COUNT_LIMIT +import org.eclipse.aether.artifact.DefaultArtifact +import org.eclipse.aether.resolution.VersionRangeRequest +import org.eclipse.aether.resolution.VersionRangeResult +import org.eclipse.aether.util.version.GenericVersionScheme +import org.junit.jupiter.api.Test +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.assertj.core.api.Assertions.assertThat + +class MuzzleVersionUtilsTest { + + private val versionScheme = GenericVersionScheme() + + @ParameterizedTest(name = "[{index}] filters pre-release: {0}") + @ValueSource( + strings = + [ + "2.0.0-SNAPSHOT", // -snapshot + "2.0.0-RC1", // rc + "2.0.0.CR1", // .cr + "2.0.0-alpha", // alpha + "2.0.0-beta.1", // beta + "2.0.0-b2", // -b + "2.0.0.M1", // .m + "2.0.0-m1", // -m + "2.0.0-dev", // -dev + "2.0.0-ea", // -ea + "2.0.0-atlassian-3", // -atlassian- + "2.0-public_draft", // public_draft + "2.0.0-cr1", // -cr + "2.0-preview", // -preview + "2.0.0.redhat-1", // redhat + "2.7.3m2", // END_NMN_PATTERN ^.*\.[0-9]+[mM][0-9]+$ + "2.0.0-1a2b3c4d", // GIT_SHA_PATTERN ^.*-[0-9a-f]{7,}$ + ]) + fun `filterAndLimitVersions filters out pre-release versions when includeSnapshots is false`( + preRelease: String + ) { + val result = createVersionRangeResult("1.0.0", preRelease, "3.0.0") + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), includeSnapshots = false) + + val filteredStrings = filtered.map { it.toString() } + assertThat(filteredStrings).withFailMessage("Expected '$preRelease' to be filtered out").doesNotContain(preRelease) + assertThat(filteredStrings).contains("1.0.0", "3.0.0") + } + + @ParameterizedTest(name = "[{index}] includeSnapshots=true keeps ''{0}'', skipVersions={1}") + @MethodSource("includeSnapshotsCases") + fun `with includeSnapshots=true, keeps pre-release versions and still respects skipVersions`( + preRelease: String, + skipVersions: Set + ) { + // preRelease major.minor = 1.0, surrounded by 2.0 and 3.0-RC1 (distinct major.minor) + val result = createVersionRangeResult(preRelease, "2.0.0", "3.0.0-RC1") + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions(result, skipVersions, includeSnapshots = true) + + val filteredStrings = filtered.map { it.toString() } + assertThat(filteredStrings) + .withFailMessage("Expected '$preRelease' to be kept when includeSnapshots=true") + .contains(preRelease) + skipVersions.forEach { skipped -> + assertThat(filteredStrings) + .withFailMessage("Expected '$skipped' to be absent due to skipVersions") + .doesNotContain(skipped) + } + } + + @ParameterizedTest(name = "[{index}] skips exact version: {0}") + @ValueSource(strings = ["1.1.0", "1.3.0", "2.0.0"]) + fun `can skip exact versions`(versionToSkip: String) { + val result = createVersionRangeResult("1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0.0", "3.0.0") + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions( + result, setOf(versionToSkip), includeSnapshots = false) + + assertThat(filtered.map { it.toString() }).doesNotContain(versionToSkip) + } + + @Test + fun `skip versions is case sensitive`() { + val result = createVersionRangeResult("1.0.0", "2.0.0-custom", "3.0.0") + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions( + result, setOf("2.0.0-Custom"), includeSnapshots = false) + + assertThat(filtered.map { it.toString() }) + .withFailMessage("Expected '2.0.0-custom' to be kept because skipVersions entry 'Custom' does not match lowercased 'custom'") + .contains("2.0.0-custom") + } + + @Test + fun `trim version range larger than the limit`() { + // 30 versions with distinct major.minor: 1.0.0, 1.1.0, ..., 1.29.0 + val versions = (0..29).map { "1.$it.0" }.toTypedArray() + val result = createVersionRangeResult(*versions) + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), includeSnapshots = false) + + assertThat(filtered).withFailMessage("Expected fewer than 25 versions after trimming, got ${filtered.size}") + .hasSizeLessThan(RANGE_COUNT_LIMIT) + assertThat(filtered).isNotEmpty() + val filteredStrings = filtered.map { it.toString() } + assertThat(filteredStrings).withFailMessage("lowestVersion (${result.lowestVersion}) must be preserved") + .contains(result.lowestVersion.toString()) + assertThat(filteredStrings).withFailMessage("highestVersion (${result.highestVersion}) must be preserved") + .contains(result.highestVersion.toString()) + assertThat(filteredStrings).withFailMessage("All filtered versions must come from the original set") + .isSubsetOf(*versions) + } + + @ParameterizedTest(name = "[{index}] {0} version(s) pass through unchanged") + @ValueSource(ints = [1, 2, 3, 10, 24]) + fun `should limit large ranges`(count: Int) { + val versionStrings = (0 until count).map { "$it.0.0" }.toTypedArray() + val result = createVersionRangeResult(*versionStrings) + + val filtered = + MuzzleVersionUtils.filterAndLimitVersions(result, emptySet(), includeSnapshots = false) + + assertThat(filtered.map { it.toString() }).containsExactlyInAnyOrder(*versionStrings) + } + + companion object { + @JvmStatic + fun includeSnapshotsCases() = listOf( + Arguments.of("1.0.0-SNAPSHOT", emptySet()), + Arguments.of("1.0.0-RC1", emptySet()), + Arguments.of("1.0.0-alpha", emptySet()), + Arguments.of("1.0.0-beta.1", emptySet()), + Arguments.of("1.0.0-b2", emptySet()), + // skipVersions is still respected even when includeSnapshots=true + Arguments.of("1.0.0-SNAPSHOT", setOf("2.0.0")), + ) + } + + private fun createVersionRangeResult(vararg versionStrings: String): VersionRangeResult { + val artifact = DefaultArtifact("com.example:test:[1.0,)") + val request = VersionRangeRequest(artifact, emptyList(), null) + val versions = versionStrings.map { versionScheme.parseVersion(it) }.sorted() + // lowestVersion/highestVersion are computed as versions[0] and versions[last] + return VersionRangeResult(request).apply { this.versions = versions } + } +} + diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/RangeQueryTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/RangeQueryTest.kt index 6c1623f62ef..5ced9ed1032 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/RangeQueryTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/RangeQueryTest.kt @@ -3,7 +3,7 @@ package datadog.gradle.plugin.muzzle import org.eclipse.aether.artifact.Artifact import org.eclipse.aether.artifact.DefaultArtifact import org.eclipse.aether.resolution.VersionRangeRequest -import org.gradle.internal.impldep.org.junit.Assert.assertTrue +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test class RangeQueryTest { @@ -22,6 +22,6 @@ class RangeQueryTest { // This call makes an actual network request, which may fail if network access is limited. val rangeResult = system.resolveVersionRange(session, rangeRequest) - assertTrue(rangeResult.versions.size >= 8) + assertThat(rangeResult.versions.size).isGreaterThanOrEqualTo(8) } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt index ac4eef08888..0d8c164e087 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/VersionSetTest.kt @@ -1,8 +1,8 @@ package datadog.gradle.plugin.muzzle import org.eclipse.aether.version.Version -import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.assertj.core.api.Assertions.assertThat class VersionSetTest { @@ -26,13 +26,11 @@ class VersionSetTest { for (c in cases) { val parsed = VersionSet.ParsedVersion(c.version) - assertEquals(c.versionNumber, parsed.versionNumber, "versionNumber for ${c.version}") - assertEquals(c.ending, parsed.ending, "ending for ${c.version}") - assertEquals( - c.versionNumber shr 12, - parsed.majorMinor.toLong(), - "majorMinor for ${c.version}" - ) + assertThat(parsed.versionNumber).withFailMessage("versionNumber for ${c.version}").isEqualTo(c.versionNumber) + assertThat(parsed.ending).withFailMessage("ending for ${c.version}").isEqualTo(c.ending) + assertThat(parsed.majorMinor.toLong()) + .withFailMessage("majorMinor for ${c.version}") + .isEqualTo(c.versionNumber shr 12) } } @@ -71,7 +69,7 @@ class VersionSetTest { versionsCases.zip(expectedCases).forEach { (versions, expected) -> val versionSet = VersionSet(versions) - assertEquals(expected, versionSet.lowAndHighForMajorMinor) + assertThat(versionSet.lowAndHighForMajorMinor).isEqualTo(expected) } } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlannerTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlannerTest.kt new file mode 100644 index 00000000000..3dce77a8ad2 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/planner/MuzzleTaskPlannerTest.kt @@ -0,0 +1,350 @@ +package datadog.gradle.plugin.muzzle.planner + +import datadog.gradle.plugin.muzzle.MuzzleDirective +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.aether.artifact.Artifact +import org.eclipse.aether.artifact.DefaultArtifact +import org.junit.jupiter.api.Test + +class MuzzleTaskPlannerTest { + + @Test + fun `empty directives list returns empty plans`() { + val fakeService = FakeResolutionService() + + val plans = MuzzleTaskPlanner(fakeService).plan(emptyList()) + + assertThat(plans).isEmpty() + assertThat(fakeService.resolveCalls).isEqualTo(0) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `directive with no resolved artifacts returns empty plans`() { + val directive = MuzzleDirective().apply { + group = "com.example" + module = "nonexistent" + versions = "[99.0,100.0)" + assertPass = true + } + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf(directive to emptySet()) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive)) + + assertThat(plans).isEmpty() + assertThat(fakeService.resolveCalls).isEqualTo(1) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `coreJdk directive does not call resolution service`() { + val directive = MuzzleDirective().apply { + assertPass = true + coreJdk() + } + val fakeService = FakeResolutionService() + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive)) + + assertThat(plans).containsExactly(MuzzleTaskPlan(directive, null)) + assertThat(fakeService.resolveCalls).isEqualTo(0) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `artifact directive creates one plan per resolved artifact version`() { + val directive = MuzzleDirective().apply { + group = "com.example" + module = "demo" + versions = "[1.0,2.0)" + assertPass = true + } + val artifacts = linkedSetOf( + artifact(version = "1.0.0"), + artifact(version = "1.1.0"), + artifact(version = "1.2.0"), + artifact(version = "1.3.0") + ) + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf(directive to artifacts) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(directive, artifact(version = "1.0.0")), + MuzzleTaskPlan(directive, artifact(version = "1.1.0")), + MuzzleTaskPlan(directive, artifact(version = "1.2.0")), + MuzzleTaskPlan(directive, artifact(version = "1.3.0")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(1) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `multiple directives processed together preserves order`() { + val directive1 = MuzzleDirective().apply { + group = "com.example" + module = "first" + versions = "[1.0,2.0)" + assertPass = true + } + val directive2 = MuzzleDirective().apply { + group = "com.example" + module = "second" + versions = "[2.0,3.0)" + assertPass = true + } + val directive3 = MuzzleDirective().apply { + group = "com.example" + module = "third" + versions = "[3.0,4.0)" + assertPass = false + } + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + directive1 to linkedSetOf(artifact("first", "1.5.0")), + directive2 to linkedSetOf(artifact("second", "2.5.0")), + directive3 to linkedSetOf(artifact("third", "3.5.0")) + ) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive1, directive2, directive3)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(directive1, artifact("first", "1.5.0")), + MuzzleTaskPlan(directive2, artifact("second", "2.5.0")), + MuzzleTaskPlan(directive3, artifact("third", "3.5.0")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(3) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `assertInverse adds inverse plans on top of declared range plans`() { + val directive = MuzzleDirective().apply { + group = "com.example" + module = "demo" + versions = "[3.0,)" + assertPass = true + assertInverse = true + } + val inversedDirective = MuzzleDirective().apply { + group = "com.example" + module = "demo" + versions = "[2.7,3.0)" + assertPass = false + } + val directArtifactV1 = artifact(version = "3.12.13") + val directArtifactV2 = artifact(version = "4.4.1") + val directArtifactV3 = artifact(version = "5.3.2") + val inverseArtifactV1 = artifact(version = "2.7.5") + val inverseArtifactV2 = artifact(version = "2.8.1") + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + directive to linkedSetOf(directArtifactV1, directArtifactV2, directArtifactV3), + inversedDirective to linkedSetOf(inverseArtifactV1, inverseArtifactV2) + ), + inverseByDirective = mapOf(directive to linkedSetOf(inversedDirective)) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(directive, directArtifactV1), + MuzzleTaskPlan(directive, directArtifactV2), + MuzzleTaskPlan(directive, directArtifactV3), + MuzzleTaskPlan(inversedDirective, inverseArtifactV1), + MuzzleTaskPlan(inversedDirective, inverseArtifactV2), + ) + assertThat(fakeService.resolveCalls) + .withFailMessage("main directive + additional one for the inverse directive") + .isEqualTo(2) + assertThat(fakeService.inverseCalls).isEqualTo(1) + } + + @Test + fun `multiple artifacts with inverse creates comprehensive plan set`() { + val directive = MuzzleDirective().apply { + group = "io.netty" + module = "netty-codec-http" + versions = "[4.1.0,)" + assertPass = true + assertInverse = true + } + val inverseDirective = MuzzleDirective().apply { + group = "io.netty" + module = "netty-codec-http" + versions = "[4.0.0,4.1.0)" + assertPass = false + } + val passArtifacts = linkedSetOf( + artifact("netty-codec-http", "4.1.0"), + artifact("netty-codec-http", "4.1.50"), + artifact("netty-codec-http", "4.2.0") + ) + val failArtifacts = linkedSetOf( + artifact("netty-codec-http", "4.0.30") + ) + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + directive to passArtifacts, + inverseDirective to failArtifacts + ), + inverseByDirective = mapOf( + directive to linkedSetOf(inverseDirective) + ) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive)) + + assertThat(plans).withFailMessage("Should have 3 pass plans + 1 inverse fail plan").hasSize(4) + assertThat(plans).containsExactly( + MuzzleTaskPlan(directive, artifact("netty-codec-http", "4.1.0")), + MuzzleTaskPlan(directive, artifact("netty-codec-http", "4.1.50")), + MuzzleTaskPlan(directive, artifact("netty-codec-http", "4.2.0")), + MuzzleTaskPlan(inverseDirective, artifact("netty-codec-http", "4.0.30")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(2) + assertThat(fakeService.inverseCalls).isEqualTo(1) + } + + @Test + fun `mix of coreJdk and artifact directives`() { + val coreJdkDirective = MuzzleDirective().apply { + assertPass = true + coreJdk() + } + val artifactDirective = MuzzleDirective().apply { + group = "com.example" + module = "demo" + versions = "[1.0,2.0)" + assertPass = true + } + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + artifactDirective to linkedSetOf(artifact("demo", "1.5.0")) + ) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(coreJdkDirective, artifactDirective)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(coreJdkDirective, null), + MuzzleTaskPlan(artifactDirective, artifact("demo", "1.5.0")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(1) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `mix of pass and fail directives`() { + val passDirective = MuzzleDirective().apply { + group = "com.example" + module = "demo" + versions = "[2.0,)" + assertPass = true + } + val failDirective = MuzzleDirective().apply { + name = "before-2.0" + group = "com.example" + module = "demo" + versions = "[,2.0)" + assertPass = false + } + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + passDirective to linkedSetOf(artifact("demo", "2.5.0"), artifact("demo", "3.0.0")), + failDirective to linkedSetOf(artifact("demo", "1.5.0")) + ) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(passDirective, failDirective)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(passDirective, artifact("demo", "2.5.0")), + MuzzleTaskPlan(passDirective, artifact("demo", "3.0.0")), + MuzzleTaskPlan(failDirective, artifact("demo", "1.5.0")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(2) + assertThat(fakeService.inverseCalls).isEqualTo(0) + } + + @Test + fun `multiple directives with assertInverse`() { + val directive1 = MuzzleDirective().apply { + group = "com.example" + module = "first" + versions = "[3.0,)" + assertPass = true + assertInverse = true + } + val directive2 = MuzzleDirective().apply { + group = "com.example" + module = "second" + versions = "[2.0,)" + assertPass = true + assertInverse = true + } + val inverse1 = MuzzleDirective().apply { + group = "com.example" + module = "first" + versions = "[2.0,3.0)" + assertPass = false + } + val inverse2 = MuzzleDirective().apply { + group = "com.example" + module = "second" + versions = "[1.0,2.0)" + assertPass = false + } + val fakeService = FakeResolutionService( + artifactsByDirective = mapOf( + directive1 to linkedSetOf(artifact("first", "3.5.0")), + directive2 to linkedSetOf(artifact("second", "2.5.0")), + inverse1 to linkedSetOf(artifact("first", "2.5.0")), + inverse2 to linkedSetOf(artifact("second", "1.5.0")) + ), + inverseByDirective = mapOf( + directive1 to linkedSetOf(inverse1), + directive2 to linkedSetOf(inverse2) + ) + ) + + val plans = MuzzleTaskPlanner(fakeService).plan(listOf(directive1, directive2)) + + assertThat(plans).containsExactly( + MuzzleTaskPlan(directive1, artifact("first", "3.5.0")), + MuzzleTaskPlan(inverse1, artifact("first", "2.5.0")), + MuzzleTaskPlan(directive2, artifact("second", "2.5.0")), + MuzzleTaskPlan(inverse2, artifact("second", "1.5.0")), + ) + assertThat(fakeService.resolveCalls).isEqualTo(4) + assertThat(fakeService.inverseCalls).isEqualTo(2) + } + + private fun artifact(module: String = "demo", version: String) = + DefaultArtifact("com.example", module, "", "jar", version) + + private class FakeResolutionService( + private val artifactsByDirective: Map> = emptyMap(), + private val inverseByDirective: Map> = emptyMap(), + ) : MuzzleResolutionService { + var resolveCalls: Int = 0 + private set + var inverseCalls: Int = 0 + private set + + override fun resolveArtifacts(directive: MuzzleDirective): Set { + resolveCalls++ + return artifactsByDirective[directive].orEmpty() + } + + override fun inverseOf(directive: MuzzleDirective): Set { + inverseCalls++ + return inverseByDirective[directive].orEmpty() + } + } +} diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleEndTaskTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleEndTaskTest.kt index e567a1611f4..2c7dc920a30 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleEndTaskTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/tasks/MuzzleEndTaskTest.kt @@ -2,9 +2,6 @@ package datadog.gradle.plugin.muzzle.tasks import org.gradle.kotlin.dsl.register import org.gradle.testfixtures.ProjectBuilder -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -15,6 +12,7 @@ import javax.xml.parsers.DocumentBuilderFactory import kotlin.io.path.createDirectories import kotlin.io.path.readText import kotlin.io.path.writeText +import org.assertj.core.api.Assertions.assertThat class MuzzleEndTaskTest { @@ -70,38 +68,38 @@ class MuzzleEndTaskTest { @Test fun `junit report contains expected testsuite counters`() { val suite = junitDoc.documentElement - assertEquals("testsuite", suite.tagName) - assertEquals(":lettuce-5.0", suite.getAttribute("name")) - assertEquals("2", suite.getAttribute("tests")) - assertEquals("1", suite.getAttribute("failures")) - assertEquals("0", suite.getAttribute("errors")) - assertEquals("0", suite.getAttribute("skipped")) + assertThat(suite.tagName).isEqualTo("testsuite") + assertThat(suite.getAttribute("name")).isEqualTo(":lettuce-5.0") + assertThat(suite.getAttribute("tests")).isEqualTo("2") + assertThat(suite.getAttribute("failures")).isEqualTo("1") + assertThat(suite.getAttribute("errors")).isEqualTo("0") + assertThat(suite.getAttribute("skipped")).isEqualTo("0") } @Test fun `passed testcase has no failure node`() { val passedTestCase = findTestCaseByName(junitDoc, "muzzle-pass") - assertNotNull(passedTestCase) - assertNull(passedTestCase.getElementsByTagName("failure").item(0)) + assertThat(passedTestCase).isNotNull() + assertThat(passedTestCase.getElementsByTagName("failure").item(0)).isNull() } @Test fun `failed testcase contains failure node and message`() { val failedTestCase = findTestCaseByName(junitDoc, "muzzle-fail") - assertNotNull(failedTestCase) + assertThat(failedTestCase).isNotNull() val failureNode = failedTestCase.getElementsByTagName("failure").item(0) as Element - assertEquals("Muzzle validation failed", failureNode.getAttribute("message")) - assertEquals("java.lang.IllegalStateException: something is broken", failureNode.textContent) + assertThat(failureNode.getAttribute("message")).isEqualTo("Muzzle validation failed") + assertThat(failureNode.textContent).isEqualTo("java.lang.IllegalStateException: something is broken") } @Test fun `legacy report keeps historical shape`() { val legacySuite = legacyDoc.documentElement - assertEquals("testsuite", legacySuite.tagName) - assertEquals("1", legacySuite.getAttribute("tests")) - assertEquals("0", legacySuite.getAttribute("id")) - assertEquals("muzzle-end", legacySuite.getAttribute("name")) - assertEquals(1, legacySuite.getElementsByTagName("testcase").length) + assertThat(legacySuite.tagName).isEqualTo("testsuite") + assertThat(legacySuite.getAttribute("tests")).isEqualTo("1") + assertThat(legacySuite.getAttribute("id")).isEqualTo("0") + assertThat(legacySuite.getAttribute("name")).isEqualTo("muzzle-end") + assertThat(legacySuite.getElementsByTagName("testcase").length).isEqualTo(1) } private fun parseXml(xml: String): Document { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9146f93c1af..abd7445ca9a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ jackson = "2.20.0" moshi = "1.11.0" # Testing +assertj = "3.27.7" junit4 = "4.13.2" junit5 = "5.14.1" junit-platform = "1.14.1" @@ -145,6 +146,7 @@ jackson-databind = {module = "com.fasterxml.jackson.core:jackson-databind", vers moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } # Testing +assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } junit4 = { module = "junit:junit", version.ref = "junit4" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit5" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" }