diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt index 616fb9e430d..285301e2613 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleExtension.kt @@ -3,7 +3,6 @@ package datadog.gradle.plugin.muzzle import org.gradle.api.Action import org.gradle.api.model.ObjectFactory import org.gradle.kotlin.dsl.newInstance -import org.gradle.kotlin.dsl.property import java.util.Locale import javax.inject.Inject @@ -11,9 +10,6 @@ import javax.inject.Inject * Muzzle extension containing all pass and fail directives. */ abstract class MuzzleExtension @Inject constructor(private val objectFactory: ObjectFactory) { - /** Whether aggregate runMuzzle invocations should plan checks for this project. */ - val includeInAggregate = objectFactory.property().convention(true) - val directives: MutableList = ArrayList() private val additionalRepositories: MutableList> = ArrayList() 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 b0971435996..fb1f4db0fb2 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzlePlugin.kt @@ -9,6 +9,7 @@ import org.eclipse.aether.artifact.Artifact import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.artifacts.Configuration import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.create @@ -26,8 +27,7 @@ class MuzzlePlugin : Plugin { * * This function sets up the necessary configurations, dependencies, and tasks for muzzle validation. * It registers the muzzle extension, configures bootstrap/tooling dependencies, and sets up tasks for - * compiling, running, printing, and reporting muzzle checks. It also optimizes configuration overhead - * by only proceeding if relevant muzzle tasks are requested. + * compiling, running, printing, and reporting muzzle checks. Validation tasks are registered lazily. * * @param project The Gradle project to apply the plugin to. */ @@ -101,46 +101,22 @@ class MuzzlePlugin : Plugin { } project.publishMuzzleReport(report.flatMap { it.versionsFile }) - val relevantTasks = project.gradle.startParameter.taskNames.filter { taskName -> - val taskProjectPath = taskName.substringBeforeLast(":", "") - val taskNameOnly = taskName.substringAfterLast(":") - val isAggregate = taskNameOnly.equals("runMuzzle", ignoreCase = true) - val isRelevantForProject = taskProjectPath.isEmpty() || taskProjectPath == project.path || - (isAggregate && project.path.startsWith("$taskProjectPath:")) - - isRelevantForProject && !taskNameOnly.equals("compileMuzzle", ignoreCase = true) && - taskNameOnly.endsWith("muzzle", ignoreCase = true) - } - if (relevantTasks.isEmpty()) { - // 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 - } - - // We only get here if we are running muzzle, so let's start timing things - val startTime = System.currentTimeMillis() - - project.afterEvaluate { - if (relevantTasks.all { it.substringAfterLast(":").equals("runMuzzle", ignoreCase = true) } && - !extension.includeInAggregate.get()) { - project.logger.info("No muzzle tasks invoked for ${project.path}, skipping muzzle task planification") - return@afterEvaluate - } + val validationTasks = MuzzleValidationTasks(project.providers) { + val startTime = System.currentTimeMillis() val system = MuzzleMavenRepoUtils.newRepositorySystem() val session = MuzzleMavenRepoUtils.newRepositorySystemSession(system) val taskPlanner = MuzzleTaskPlanner.from(system, session) - // use runAfter to set up task finalizers in version order - var runAfter: TaskProvider = muzzleTask - val muzzleReportTasks = mutableListOf>() val directives = project.extensions.getByType().directives - taskPlanner.plan(directives).forEach { plan -> - runAfter = registerMuzzleTask(plan.directive, plan.artifact, project, runAfter, muzzleBootstrap, muzzleTooling) - muzzleReportTasks.add(runAfter) + val checks = taskPlanner.plan(directives).map { plan -> + val check = registerMuzzleTask(plan.directive, plan.artifact, project, muzzleBootstrap, muzzleTooling) project.logger.info("configured ${plan.directive}") + check } - if (muzzleReportTasks.isEmpty() && !directives.any { it.assertPass }) { - muzzleReportTasks.add(muzzleTask) + val muzzleReportTasks = if (checks.isEmpty() && !directives.any { it.assertPass }) { + listOf(muzzleTask) + } else { + checks } val timingTask = project.tasks.register("muzzle-end") { @@ -152,10 +128,17 @@ class MuzzlePlugin : Plugin { ) muzzleResultFiles.from(muzzleReportTasks.map { it.flatMap { task -> task.result } }) } - // last muzzle task to run - runAfter.configure { - finalizedBy(timingTask) + + val executionChain: List> = checks + timingTask + executionChain.zipWithNext { current, next -> + current.configure { finalizedBy(next) } } + + // Note this is unordered set, the execution order is configured via the finalizer relationships + setOf(executionChain.first().get()) + } + muzzleTask.configure { + finalizedBy(validationTasks.tasks) } } @@ -169,7 +152,6 @@ class MuzzlePlugin : Plugin { * @param muzzleDirective The directive describing the dependency and test parameters. * @param versionArtifact The artifact representing the dependency version to test (may be null when `muzzleDirective.coreJdk` is true). * @param instrumentationProject The Gradle project to register the task in. - * @param runAfterTask The task provider for the task that this muzzle task should run after. * @param muzzleBootstrap The configuration provider for agent bootstrap dependencies. * @param muzzleTooling The configuration provider for agent tooling dependencies. * @return The muzzle task provider. @@ -178,7 +160,6 @@ class MuzzlePlugin : Plugin { muzzleDirective: MuzzleDirective, versionArtifact: Artifact?, instrumentationProject: Project, - runAfterTask: TaskProvider, muzzleBootstrap: NamedDomainObjectProvider, muzzleTooling: NamedDomainObjectProvider ): TaskProvider { @@ -246,9 +227,6 @@ class MuzzlePlugin : Plugin { this.muzzleTooling.set(muzzleTooling) } - runAfterTask.configure { - finalizedBy(muzzleTask) - } return muzzleTask } } diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt new file mode 100644 index 00000000000..5f44da43158 --- /dev/null +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasks.kt @@ -0,0 +1,41 @@ +package datadog.gradle.plugin.muzzle + +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.TaskProvider + +/** + * Registers validation tasks when Gradle resolves a module's muzzle finalizers. + * + * [tasks] is a plain [Provider] used as dependency notation by [Task.finalizedBy]. During normal + * task-graph construction, Gradle resolves its task-set value after project evaluation and before + * execution. Registration therefore runs outside task configuration actions and can use completed + * directives to discover checks whose names were unknown when the plugin was applied. + * + * A [TaskProvider] references an already registered task. A task-backed [Provider.map] can supply + * producer dependencies without evaluating its mapping, so registration must not be a side effect + * of that mapping. [ProviderFactory.provider] supplies the task-set value directly instead. + * + * Providers can be queried repeatedly. Cache both success and failure to avoid duplicate task + * registration, including retries after partial registration. + * + * Note that [org.gradle.api.tasks.TaskDependency], a mechanism to supplying directly the **concrete** + * tasks when assembling the execution graph, exists today but is on the side-line possibly + * getting deprecated for removal. + * + * shortcut: generated-check CLI selectors remain unsupported; revisit if individual checks + * need to be selected or excluded. + * + * @see ProviderFactory.provider API + * @see TaskProvider API + * @see Discussion on removing older Gradle interface API + */ +internal class MuzzleValidationTasks( + providerFactory: ProviderFactory, + registerValidationTasks: () -> Set +) { + private val registration by lazy { runCatching(registerValidationTasks) } + + val tasks: Provider> = providerFactory.provider { registration.getOrThrow() } +} diff --git a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts index 8dded4f95ad..39a86ce9cad 100644 --- a/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts +++ b/buildSrc/src/main/kotlin/dd-trace-java.ci-jobs.gradle.kts @@ -1,5 +1,4 @@ import datadog.gradle.plugin.ci.isInSelectedSlot -import datadog.gradle.plugin.muzzle.MuzzleExtension import org.gradle.api.tasks.testing.Test import java.io.File @@ -16,12 +15,6 @@ if (project != rootProject) { } allprojects { - pluginManager.withPlugin("dd-trace-java.muzzle") { - extensions.configure { - includeInAggregate.convention(project.isInSelectedSlot) - } - } - // Enable tests only on the selected slot (if -Pslot=n/t is provided) tasks.withType().configureEach { onlyIf("Project is in selected slot") { @@ -133,7 +126,7 @@ tasks.register("runMuzzle") { val muzzleSubprojects = subprojects.filter { p -> p.plugins.hasPlugin("java") && p.plugins.hasPlugin("dd-trace-java.muzzle") && - p.extensions.getByType().includeInAggregate.get() + p.isInSelectedSlot.get() } dependsOn(muzzleSubprojects.map { p -> "${p.path}:muzzle" }) } diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt index 779b8fce1f8..75107a2f41a 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginFunctionalTest.kt @@ -8,8 +8,9 @@ import org.junit.jupiter.params.provider.ValueSource import kotlin.io.path.readText class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { - @Test - fun `nested aggregate plans descendant instrumentation checks`() { + @ParameterizedTest + @ValueSource(strings = [":dd-java-agent:runMuzzle", "dd-java-agent:runMuzzle"]) + fun `nested aggregate plans descendant instrumentation checks`(taskName: String) { writeProject( """ plugins { @@ -25,14 +26,14 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } """ ) - val result = run(":dd-java-agent:runMuzzle") + val result = run(taskName) assertThat(result.output).contains("BUILD SUCCESSFUL") assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) } @ParameterizedTest - @ValueSource(strings = ["muzzle", ":dd-java-agent:instrumentation:demo:muzzle", "runMuzzle"]) - fun `detects muzzle invocation with various task names`(taskName: String) { + @ValueSource(strings = ["muzzle", ":dd-java-agent:instrumentation:demo:muzzle", "runMuzzle", "verifyCompatibility"]) + fun `runs validation through different task entry points`(taskName: String) { writeProject( """ plugins { @@ -54,6 +55,9 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + tasks.register("verifyCompatibility") { + dependsOn(":dd-java-agent:instrumentation:demo:muzzle") + } """ ) @@ -61,14 +65,13 @@ class MuzzlePluginFunctionalTest : MuzzlePluginTestFixture() { val result = 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") } + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .withFailMessage("Should execute validation through '$taskName'") + .isEqualTo(SUCCESS) + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-end")?.outcome) + .withFailMessage("Should execute reporting through '$taskName'") + .isEqualTo(SUCCESS) } @Test diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt index 8d42d5d4e09..b89c90f74c5 100644 --- a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzlePluginPerformanceTest.kt @@ -3,9 +3,24 @@ package datadog.gradle.plugin.muzzle 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.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import org.assertj.core.api.Assertions.assertThat +import kotlin.math.abs class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { + private fun writeCoreProject() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeNoopScanPlugin() + } @Test fun `compileMuzzle does not resolve version directives or schedule checks`() { @@ -28,7 +43,36 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { assertThat(result.output).contains("BUILD SUCCESSFUL") assertThat(result.tasks).noneMatch { it.path.contains("muzzle-Assert") || it.path.endsWith("muzzle-end") } assertThat(result.task(":dd-java-agent:instrumentation:demo:compileMuzzle")?.outcome).isEqualTo(UP_TO_DATE) - assertThat(result.output).contains("skipping muzzle task planification") + } + + @Test + fun `aggregate does not plan an excluded module`() { + writeProject( + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } + } + """ + ) + writeRootProject( + """ + tasks.register("runMuzzle") { dependsOn(":dd-java-agent:instrumentation:demo:muzzle") } + """ + ) + + val result = run(":runMuzzle", "-x", ":dd-java-agent:instrumentation:demo:muzzle", "--offline") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.tasks).noneMatch { it.path.startsWith(":dd-java-agent:instrumentation:demo:") } + assertThat(result.output).doesNotContain("muzzle-Assert", "muzzle-end") } @Test @@ -49,10 +93,7 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { id("java") id("dd-trace-java.muzzle") } - muzzle { - includeInAggregate.set(false) - pass { coreJdk() } - } + muzzle { pass { coreJdk() } } """ ) writeRootProject( @@ -62,8 +103,8 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { ) val aggregate = run("runMuzzle", "--info") assertThat(aggregate.output).contains("BUILD SUCCESSFUL") - assertThat(aggregate.output).contains("No muzzle tasks invoked for :dd-java-agent:instrumentation:other") assertThat(aggregate.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) + assertThat(aggregate.tasks).noneMatch { it.path.contains(":other:") } val explicit = run(":dd-java-agent:instrumentation:other:muzzle") assertThat(explicit.output).contains("BUILD SUCCESSFUL") assertThat(explicit.task(":dd-java-agent:instrumentation:other:muzzle-AssertPass-core-jdk")?.outcome).isEqualTo(SUCCESS) @@ -81,8 +122,8 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { muzzle { pass { - group = "com.example.test" - module = "some-lib" + group = "does.not.exist" + module = "must-not-resolve" versions = "[1.0.0,2.0.0)" } } @@ -93,21 +134,21 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { val result = run( ":dd-java-agent:instrumentation:demo:tasks", "--all", + "--offline", "--info" ) assertThat(result.task(":dd-java-agent:instrumentation:demo:tasks")?.outcome).isEqualTo(SUCCESS) + assertThat(result.output).contains("BUILD SUCCESSFUL") 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") + assertThat(result.output).doesNotContain("muzzle-Assert", "muzzle-end") } @Test - fun `does not configure muzzle when other project muzzle task is requested`() { + fun `does not plan checks when another project's muzzle task is requested`() { writeProject( """ plugins { @@ -127,26 +168,105 @@ class MuzzlePluginPerformanceTest : MuzzlePluginTestFixture() { id("dd-trace-java.muzzle") } muzzle { - pass { coreJdk() } + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } } """ ) val result = run( ":dd-java-agent:instrumentation:demo:muzzle", + "--offline", "--stacktrace", "--info" ) + assertThat(result.output).contains("BUILD SUCCESSFUL") 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") } + } + + @ParameterizedTest + @ValueSource(ints = [1, 2]) + fun `CI aggregate runs only the module assigned to its slot`(slot: Int) { + writeCoreProject() + addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { pass { coreJdk() } } + """ + ) + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + val otherSlot = abs(":dd-java-agent:instrumentation:other".hashCode() % 2) + 1 + assertThat(demoSlot).isNotEqualTo(otherSlot) + + val result = run(":runMuzzle", "-Pslot=$slot/2") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + val selectedModule = if (slot == demoSlot) "demo" else "other" + val unselectedModule = if (slot == demoSlot) "other" else "demo" + assertThat(result.task(":dd-java-agent:instrumentation:$selectedModule:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.tasks).noneMatch { it.path.contains(":$unselectedModule:") } + } + + @Test + fun `CI aggregate does not resolve off-slot directives`() { + writeCoreProject() + addSubproject("dd-java-agent:instrumentation:other", + """ + plugins { + id("java") + id("dd-trace-java.muzzle") + } + muzzle { + pass { + group = "does.not.exist" + module = "must-not-resolve" + versions = "[1.0,2.0)" + } + } + """ + ) + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + + val result = run(":runMuzzle", "-Pslot=$demoSlot/2", "--offline") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) + assertThat(result.tasks).noneMatch { it.path.contains(":other:") } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `explicit module check bypasses CI slot selection`(includeAggregate: Boolean) { + writeCoreProject() + writeRootProject("""plugins { id("dd-trace-java.ci-jobs") }""") + val demoSlot = abs(":dd-java-agent:instrumentation:demo".hashCode() % 2) + 1 + val taskNames = if (includeAggregate) { + arrayOf(":runMuzzle", ":dd-java-agent:instrumentation:demo:muzzle") + } else { + arrayOf(":dd-java-agent:instrumentation:demo:muzzle") + } + + val result = run(*taskNames, "-Pslot=${3 - demoSlot}/2") + + assertThat(result.output).contains("BUILD SUCCESSFUL") + assertThat(result.task(":dd-java-agent:instrumentation:demo:muzzle-AssertPass-core-jdk")?.outcome) + .isEqualTo(SUCCESS) } @Test diff --git a/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt new file mode 100644 index 00000000000..8cd396cd975 --- /dev/null +++ b/buildSrc/src/test/kotlin/datadog/gradle/plugin/muzzle/MuzzleValidationTasksTest.kt @@ -0,0 +1,47 @@ +package datadog.gradle.plugin.muzzle + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test + +class MuzzleValidationTasksTest { + @Test + fun `registration is deferred and repeated provider queries reuse the tasks`() { + val project = ProjectBuilder.builder().build() + var attempts = 0 + val validationTasks = MuzzleValidationTasks(project.providers) { + attempts++ + setOf(project.tasks.register("validation").get()) + } + + assertThat(attempts).isZero() + assertThat(project.tasks.names).doesNotContain("validation") + + val first = validationTasks.tasks.get() + val second = validationTasks.tasks.get() + + assertThat(attempts).isEqualTo(1) + assertThat(first).containsExactly(project.tasks.named("validation").get()) + assertThat(second).isSameAs(first) + } + + @Test + fun `failed registration is not retried after registering a task`() { + val project = ProjectBuilder.builder().build() + val failure = IllegalStateException("Registration failed") + var attempts = 0 + val validationTasks = MuzzleValidationTasks(project.providers) { + attempts++ + project.tasks.register("partiallyRegistered") + throw failure + } + + repeat(2) { + assertThatThrownBy { validationTasks.tasks.get() }.isSameAs(failure) + } + + assertThat(attempts).isEqualTo(1) + assertThat(project.tasks.names).contains("partiallyRegistered") + } +}