diff --git a/README.md b/README.md index a0b0468..2d53c39 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,19 @@ src/test/projects/MyTest/sometest: settings.gradle.kts # optional, but IntelliJ will complain ``` -In the test project's `build.gradle.kts`, make sure to apply the coverage plugin. +Test project files may contain Ant-style placeholders. The predefined placeholders are: -``` +* `@TESTKIT_PLUGIN_VERSION@` - the version of this project +* `@TESTKIT_INTEGRATION_REPO@` - the location of the integration repository, see below +* `@VERSION@` - the version of the plugin under test + +In the test project's `build.gradle.kts`, make sure to apply the coverage plugin, in addition to the plugin under test. +Note that both plugins require versions which can be specified using the placeholders above. + +```kotlin plugins { - id("com.toasttab.testkit.coverage") + id("com.toasttab.testkit.coverage") version "@TESTKIT_PLUGIN_VERSION@" + id("my.plugin.under.test") version "@VERSION@" } ``` @@ -50,9 +58,7 @@ Now, write the actual test. Note that a `TestProject` instance will be automatic class MyTest { @Test fun sometest(project: TestProject) { - project.createRunner() - .withArguments("check") - .build() + project.build("check") } } ``` @@ -77,6 +83,16 @@ class ParameterizedTest { } ``` +## Integration repository + +This plugin does not use the TestKit's plugin classpath injection mechanism because the mechanism breaks +in certain scenarios, e.g. when plugins depend on other plugins. Instead, this plugin installs +the plugin under test and its sibling dependencies, optionally preinstrumented for Jacoco code coverage, +into an integration repository on disk, an technique borrowed from [DAGP](https://github.com/autonomousapps/dependency-analysis-gradle-plugin). + +The integration repository is then injected into the plugin management repositories via a custom init +script which is generated on the fly. + ## Code coverage It is notoriously difficult to collect code coverage data from TestKit tests. By default, TestKit tests launch in diff --git a/coverage-plugin/build.gradle.kts b/coverage-plugin/build.gradle.kts index 909133f..8759d96 100644 --- a/coverage-plugin/build.gradle.kts +++ b/coverage-plugin/build.gradle.kts @@ -1,12 +1,30 @@ plugins { `kotlin-conventions` - `library-publishing-conventions` + `plugin-publishing-conventions` jacoco } +gradlePlugin { + plugins { + create("jacoco") { + id = "com.toasttab.testkit.coverage" + implementationClass = "com.toasttab.gradle.testkit.FlushJacocoPlugin" + description = ProjectInfo.description + displayName = ProjectInfo.name + tags = listOf("jacoco", "testkit") + } + } +} + dependencies { implementation(gradleApi()) implementation(projects.jacocoReflect) + implementation(libs.jacoco.agent) { + artifact { + classifier = "runtime" + extension = "jar" + } + } testImplementation(projects.junit5) testImplementation(libs.junit) diff --git a/coverage-plugin/src/main/kotlin/com/toasttab/gradle/testkit/FlushJacocoPlugin.kt b/coverage-plugin/src/main/kotlin/com/toasttab/gradle/testkit/FlushJacocoPlugin.kt index be07581..47b7684 100644 --- a/coverage-plugin/src/main/kotlin/com/toasttab/gradle/testkit/FlushJacocoPlugin.kt +++ b/coverage-plugin/src/main/kotlin/com/toasttab/gradle/testkit/FlushJacocoPlugin.kt @@ -33,6 +33,9 @@ class FlushJacocoPlugin @Inject constructor( class DumpAction : FlowAction { override fun execute(parameters: FlowParameters.None) { - JacocoRt.requiredAgent.dump(false) + JacocoRt.requiredAgent.run { + writeExecutionData(true) + shutdown() + } } } diff --git a/coverage-plugin/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.coverage.properties b/coverage-plugin/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.coverage.properties deleted file mode 100644 index eba097a..0000000 --- a/coverage-plugin/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.coverage.properties +++ /dev/null @@ -1 +0,0 @@ -implementation-class=com.toasttab.gradle.testkit.FlushJacocoPlugin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 820588e..e597673 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.re gradle-publish = { module = "com.gradle.publish:plugin-publish-plugin", version = "1.2.0" } jacoco-core = { module = "org.jacoco:org.jacoco.core", version.ref = "jacoco" } +jacoco-agent = { module = "org.jacoco:org.jacoco.agent", version.ref = "jacoco" } # test junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/integration-tests/build.gradle.kts b/integration-tests/build.gradle.kts index 32f5456..ab81eb5 100644 --- a/integration-tests/build.gradle.kts +++ b/integration-tests/build.gradle.kts @@ -1,11 +1,34 @@ -import com.toasttab.gradle.testkit.shared.configureInstrumentation +import com.toasttab.gradle.testkit.shared.RepositoryDescriptor +import com.toasttab.gradle.testkit.shared.configureIntegrationPublishing +import com.toasttab.gradle.testkit.shared.publishOnlyIf plugins { `kotlin-conventions` jacoco + `plugin-publishing-conventions` } -configureInstrumentation(projects.coveragePlugin) +gradlePlugin { + plugins { + create("integration") { + id = "com.toasttab.testkit.integration.test" + implementationClass = "com.toasttab.gradle.testkit.TestPlugin" + description = ProjectInfo.description + displayName = ProjectInfo.name + tags = listOf("jacoco", "testkit") + } + } +} + +tasks { + test { + systemProperty("version", "$version") + systemProperty("testkit-integration-repo", rootProject.layout.buildDirectory.dir("integration-repo").get().asFile.path) + } +} + +configureIntegrationPublishing("testRuntimeClasspath") +publishOnlyIf { _, repo -> repo == RepositoryDescriptor.INTEGRATION } dependencies { implementation(gradleApi()) @@ -15,4 +38,5 @@ dependencies { testImplementation(projects.junit5) testImplementation(gradleTestKit()) testImplementation(libs.jacoco.core) + testImplementation(projects.coveragePlugin) } diff --git a/integration-tests/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.test.properties b/integration-tests/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.test.properties deleted file mode 100644 index a61c2e6..0000000 --- a/integration-tests/src/main/resources/META-INF/gradle-plugins/com.toasttab.testkit.test.properties +++ /dev/null @@ -1 +0,0 @@ -implementation-class=com.toasttab.gradle.testkit.TestPlugin diff --git a/integration-tests/src/test/kotlin/com/toasttab/gradle/testkit/FlushJacocoPluginIntegrationTest.kt b/integration-tests/src/test/kotlin/com/toasttab/gradle/testkit/FlushJacocoPluginIntegrationTest.kt index bcffac5..907ea31 100644 --- a/integration-tests/src/test/kotlin/com/toasttab/gradle/testkit/FlushJacocoPluginIntegrationTest.kt +++ b/integration-tests/src/test/kotlin/com/toasttab/gradle/testkit/FlushJacocoPluginIntegrationTest.kt @@ -15,7 +15,6 @@ package com.toasttab.gradle.testkit -import org.gradle.testkit.runner.GradleRunner import org.jacoco.core.data.ExecutionDataReader import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir @@ -31,6 +30,8 @@ class FlushJacocoPluginIntegrationTest { @Test fun `coverage is flushed`() { + val version = System.getProperty("version") + val file = dir.resolve("build/testkit.exec") dir.resolve("gradle.properties").writeText( @@ -41,18 +42,13 @@ class FlushJacocoPluginIntegrationTest { """ plugins { java - id("com.toasttab.testkit.coverage") - id("com.toasttab.testkit.test") + id("com.toasttab.testkit.coverage") version("$version") + id("com.toasttab.testkit.integration.test") version("$version") } """.trimIndent() ) - GradleRunner.create() - .withGradleVersion("8.7") - .withProjectDir(dir.toFile()) - .let(TestProjectExtension.pluginClasspath()::apply) - .withArguments("build", "--configuration-cache") - .build() + TestProjectExtension.createProject(dir, GradleVersionArgument.of("8.7")).build("build", "--configuration-cache", "--stacktrace") val classes = hashSetOf() diff --git a/jacoco-reflect/src/main/kotlin/com/toasttab/gradle/testkit/jacoco/JacocoRt.kt b/jacoco-reflect/src/main/kotlin/com/toasttab/gradle/testkit/jacoco/JacocoRt.kt index dd85b6c..4e5282b 100644 --- a/jacoco-reflect/src/main/kotlin/com/toasttab/gradle/testkit/jacoco/JacocoRt.kt +++ b/jacoco-reflect/src/main/kotlin/com/toasttab/gradle/testkit/jacoco/JacocoRt.kt @@ -17,11 +17,14 @@ package com.toasttab.gradle.testkit.jacoco interface JacocoAgent { val location: String - fun dump(reset: Boolean) + + fun writeExecutionData(reset: Boolean) val includes: String val excludes: String + + fun shutdown() } private class ReflectiveJacocoAgent( @@ -32,6 +35,11 @@ private class ReflectiveJacocoAgent( agent.javaClass.getDeclaredField("options").apply { isAccessible = true }.get(agent) } + private val output by lazy { + // agent.output + agent.javaClass.getDeclaredField("output").apply { isAccessible = true }.get(agent) + } + override val location: String get() = agent.javaClass.protectionDomain.codeSource.location.file override val includes: String @@ -39,8 +47,12 @@ private class ReflectiveJacocoAgent( override val excludes: String get() = options.javaClass.getMethod("getExcludes").invoke(options) as String - override fun dump(reset: Boolean) { - agent.javaClass.getMethod("dump", Boolean::class.java).invoke(agent, reset) + override fun writeExecutionData(reset: Boolean) { + output.javaClass.getMethod("writeExecutionData", Boolean::class.java).invoke(output, reset) + } + + override fun shutdown() { + output.javaClass.getMethod("shutdown").invoke(output) } } diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/CoverageRecorder.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/CoverageRecorder.kt index 7b0214f..aafa3df 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/CoverageRecorder.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/CoverageRecorder.kt @@ -15,11 +15,7 @@ package com.toasttab.gradle.testkit -import org.jacoco.core.data.ExecutionData import org.jacoco.core.data.ExecutionDataWriter -import org.jacoco.core.data.IExecutionDataVisitor -import org.jacoco.core.data.ISessionInfoVisitor -import org.jacoco.core.data.SessionInfo import org.jacoco.core.runtime.RemoteControlReader import org.jacoco.core.runtime.RemoteControlWriter import org.junit.jupiter.api.extension.ExtensionContext @@ -29,42 +25,53 @@ import kotlin.concurrent.thread internal class CoverageRecorder( settings: CoverageSettings -) : ExtensionContext.Store.CloseableResource, ISessionInfoVisitor, IExecutionDataVisitor { +) : ExtensionContext.Store.CloseableResource { private val server = ServerSocket(0) private val output = FileOutputStream(settings.output, true) private val writer = ExecutionDataWriter(output) + private val threads = mutableListOf() + private val runner = thread { - val sock = server.accept() + while (!server.isClosed) { + val sock = server.accept() - RemoteControlWriter(sock.getOutputStream()) + threads.add( + thread { + RemoteControlWriter(sock.getOutputStream()) - val reader = RemoteControlReader(sock.getInputStream()) - reader.setSessionInfoVisitor(this) - reader.setExecutionDataVisitor(this) + val reader = RemoteControlReader(sock.getInputStream()) + reader.setSessionInfoVisitor { } - while (reader.read()) { - } + reader.setExecutionDataVisitor { ex -> + synchronized(writer) { + writer.visitClassExecution(ex) + } + } - writer.flush() - sock.close() - } + reader.setRemoteCommandVisitor { _, _ -> } - val port: Int get() = server.localPort + while (reader.read()) { + } - override fun visitSessionInfo(sess: SessionInfo) { - writer.visitSessionInfo(sess) + synchronized(writer) { + writer.flush() + } + sock.close() + } + ) + } } - override fun visitClassExecution(ex: ExecutionData) { - writer.visitClassExecution(ex) - } + val port: Int get() = server.localPort override fun close() { - runner.join(10000) - server.close() + for (thread in threads) { + thread.join(10000) + } + server.close() output.close() } } diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProject.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProject.kt index 857b6de..8a48535 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProject.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProject.kt @@ -23,27 +23,12 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.deleteRecursively -sealed interface PluginClasspath { - fun apply(runner: GradleRunner): GradleRunner - - object Default : PluginClasspath { - override fun apply(runner: GradleRunner) = runner.withPluginClasspath() - } - - class Custom( - private val paths: List - ) : PluginClasspath { - override fun apply(runner: GradleRunner) = runner.withPluginClasspath(paths.map(Path::toFile)) - } -} - class TestProject( val dir: Path, - private val classpath: PluginClasspath, private val gradleVersion: GradleVersionArgument, private val cleanup: Boolean, + private val initArgs: List = emptyList() ) { - companion object { private val LOGGER = LoggerFactory.getLogger(TestProject::class.java) } @@ -58,9 +43,13 @@ class TestProject( } } - fun createRunner() = createRunnerWithoutPluginClasspath().let(classpath::apply) + fun build(vararg args: String) = createRunner(*args).build() + + fun buildAndFail(vararg args: String) = createRunner(*args).buildAndFail() + + private fun createRunner(vararg args: String) = createRunner().withArguments(initArgs + args) - fun createRunnerWithoutPluginClasspath() = GradleRunner.create() + private fun createRunner() = GradleRunner.create() .withProjectDir(dir.toFile()) .forwardStdOutput(output) .forwardStdError(output).apply { @@ -68,6 +57,7 @@ class TestProject( withGradleVersion(gradleVersion.version) } } + .withArguments() fun logOutputOnce() { if (!outputLogged.getAndSet(true)) { diff --git a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt index 97d074c..5a7210f 100644 --- a/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt +++ b/junit5/src/main/kotlin/com/toasttab/gradle/testkit/TestProjectExtension.kt @@ -25,7 +25,6 @@ import org.junit.jupiter.api.extension.ParameterContext import org.junit.jupiter.api.extension.ParameterResolver import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.ArgumentsProvider -import java.io.File import java.nio.file.Path import java.util.* import java.util.concurrent.ConcurrentHashMap @@ -37,7 +36,6 @@ import kotlin.io.path.copyToRecursively import kotlin.io.path.createFile import kotlin.io.path.createTempDirectory import kotlin.io.path.exists -import kotlin.io.path.listDirectoryEntries private val NAMESPACE = ExtensionContext.Namespace.create(TestProjectExtension::class.java.name, "testkit-project") private const val COVERAGE_RECORDER = "coverage-recorder" @@ -134,6 +132,7 @@ class TestProjectExtension : ParameterResolver, BeforeAllCallback, AfterTestExec if (coverage != null) { val collector = get(NAMESPACE, COVERAGE_RECORDER) + tempProjectDir.resolve("gradle.properties").apply { if (!exists()) { createFile() @@ -153,24 +152,45 @@ class TestProjectExtension : ParameterResolver, BeforeAllCallback, AfterTestExec } } - TestProject(tempProjectDir, pluginClasspath(), gradleVersion, parameters.cleanup) + createProject(tempProjectDir, gradleVersion, parameters.cleanup) } } - fun pluginClasspath(): PluginClasspath { - val instrumentedProperty = System.getProperty("testkit-plugin-instrumented-jars") - - return if (instrumentedProperty != null) { - val classpath = Path(instrumentedProperty).listDirectoryEntries().toMutableList() - - System.getProperty("testkit-plugin-external-jars").split(File.pathSeparatorChar).mapTo(classpath, ::Path) - System.getProperty("testkit-coverage-jars").split(File.pathSeparatorChar).mapTo(classpath, ::Path) - classpath.add(Path(System.getProperty("testkit-plugin-jacoco-jar"))) + fun createProject(projectDir: Path, gradleVersion: GradleVersionArgument, cleanup: Boolean = true): TestProject { + val integrationRepo = System.getProperty("testkit-integration-repo") + + val initArgs = if (integrationRepo != null) { + projectDir.appendToFile( + "init.gradle.kts", + """ + + settingsEvaluated { + pluginManagement { + repositories { + maven(url = "file://$integrationRepo") + gradlePluginPortal() + } + } + } + """.trimIndent() + ) - PluginClasspath.Custom(classpath) + listOf("--init-script", "init.gradle.kts") } else { - PluginClasspath.Default + emptyList() } + + return TestProject(projectDir, gradleVersion, cleanup, initArgs) } } } + +private fun Path.appendToFile(fileName: String, text: String) { + val file = resolve(fileName) + + if (!file.exists()) { + file.createFile() + } + + file.appendText(text) +} diff --git a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TestKitIntegrationTest.kt b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TestKitIntegrationTest.kt index a01bb94..ee17c1a 100644 --- a/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TestKitIntegrationTest.kt +++ b/junit5/src/test/kotlin/com/toasttab/gradle/testkit/TestKitIntegrationTest.kt @@ -24,13 +24,13 @@ class TestKitIntegrationTest { @Test fun `basic project`(project: TestProject) { expectThat( - project.createRunnerWithoutPluginClasspath().withArguments("dependencies").build().output + project.build("dependencies").output ).contains("compileClasspath") } @ParameterizedWithGradleVersions fun `basic parameterized project`(project: TestProject) { - val output = project.createRunnerWithoutPluginClasspath().withArguments("dependencies").build().output + val output = project.build("dependencies").output expectThat( output diff --git a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/CopyLocalJarsTask.kt b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/CopyLocalJarsTask.kt deleted file mode 100644 index f77f8f7..0000000 --- a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/CopyLocalJarsTask.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2024 Toast Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.toasttab.gradle.testkit.shared - -import org.gradle.api.DefaultTask -import org.gradle.api.artifacts.ArtifactCollection -import org.gradle.api.artifacts.result.ArtifactResult -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.TaskProvider -import org.gradle.jvm.tasks.Jar - -abstract class CopyLocalJarsTask : DefaultTask() { - @get:InputFiles - val artifactFiles get() = artifacts.artifactFiles - - @Internal - lateinit var artifacts: ArtifactCollection - - @Internal - lateinit var jar: TaskProvider - - @get:InputFile - val jarFile get() = jar.map { it.archiveFile } - - @OutputDirectory - lateinit var dir: Any - - @TaskAction - fun copy() { - project.sync { - from(artifacts.filter(ArtifactResult::isProject).map { - it.file - }) - - from(jar) - - into(dir) - } - } -} \ No newline at end of file diff --git a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/InstrumentWithJacocoOfflineTask.kt b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/InstrumentWithJacocoOfflineTask.kt index 52c9de1..e10e5de 100644 --- a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/InstrumentWithJacocoOfflineTask.kt +++ b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/InstrumentWithJacocoOfflineTask.kt @@ -18,8 +18,10 @@ package com.toasttab.gradle.testkit.shared import org.gradle.api.DefaultTask import org.gradle.api.artifacts.Configuration import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction @@ -29,17 +31,15 @@ abstract class InstrumentWithJacocoOfflineTask : DefaultTask() { @InputFiles lateinit var classpath: Configuration - @InputDirectory - lateinit var jars: Provider + @InputFile + lateinit var jar: Provider @OutputDirectory lateinit var dir: Provider @TaskAction fun instrument() { - project.delete { - delete(dir) - } + val file = jar.get().asFile project.ant.withGroovyBuilder { "taskdef"( @@ -48,8 +48,8 @@ abstract class InstrumentWithJacocoOfflineTask : DefaultTask() { "classpath" to classpath.asPath ) "instrument"("destdir" to dir.get().asFile.path) { - "fileset"("dir" to jars.get().asFile.path) + "fileset"("dir" to file.parent, "includes" to file.name) } } } -} +} \ No newline at end of file diff --git a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/IntegrationPublishing.kt b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/IntegrationPublishing.kt new file mode 100644 index 0000000..1e8addf --- /dev/null +++ b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/IntegrationPublishing.kt @@ -0,0 +1,148 @@ +package com.toasttab.gradle.testkit.shared + +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.attributes.Attribute +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.publish.maven.tasks.PublishToMavenLocal +import org.gradle.api.publish.maven.tasks.PublishToMavenRepository +import org.gradle.api.publish.tasks.GenerateModuleMetadata +import org.gradle.jvm.tasks.Jar +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register +import org.gradle.kotlin.dsl.withType +import org.gradle.plugin.devel.GradlePluginDevelopmentExtension +import org.gradle.testing.jacoco.plugins.JacocoPlugin + +sealed interface RepositoryDescriptor { + object MavenLocal : RepositoryDescriptor + data class MavenRemote(val name: String) : RepositoryDescriptor + + companion object { + const val INTEGRATION_REPO_NAME = "integration" + val INTEGRATION = MavenRemote(INTEGRATION_REPO_NAME) + } +} + +data class PublicationDescriptor(val name: String) { + fun isPlugin() = name.endsWith("PluginMarkerMaven") + + companion object { + const val INTEGRATION_PUBLICATION_NAME = "integration" + val INTEGRATION = PublicationDescriptor(INTEGRATION_PUBLICATION_NAME) + } +} + +fun Project.publishOnlyIf(predicate: (PublicationDescriptor, RepositoryDescriptor) -> Boolean) { + project.tasks.withType { + onlyIf { + predicate(PublicationDescriptor(publication.name), RepositoryDescriptor.MavenLocal) + } + } + + project.tasks.withType { + onlyIf { + predicate(PublicationDescriptor(publication.name), RepositoryDescriptor.MavenRemote(repository.name)) + } + } +} + +val Project.integrationRepo get() = rootProject.layout.buildDirectory.dir("integration-repo").get().asFile.path + +fun Project.configureIntegrationPublishing( + configuration: String = "runtimeClasspath" +) { + val repo = integrationRepo + + afterEvaluate { + val jacocoAnt = project.configurations.findByName(JacocoPlugin.ANT_CONFIGURATION_NAME) + + configurations.getAt(configuration).incoming.artifactView { + lenient(true) + attributes.attribute(Attribute.of("artifactType", String::class.java), "jar") + }.artifacts.map { + it.id.componentIdentifier + }.filterIsInstance().forEach { + configureIntegrationPublishingForDependency(project(":${it.projectPath}"), repo, jacocoAnt) + } + + configureIntegrationPublishingForDependency(this, repo, jacocoAnt) + } + + tasks.named("test") { + dependsOn("publishIntegrationPublicationToIntegrationRepository") + } +} + +private fun Project.configureIntegrationPublishingForDependency(project: Project, repo: Any, jacocoAnt: Configuration?) { + project.pluginManager.apply("maven-publish") + + if (jacocoAnt != null) { + project.tasks.register("instrument") { + dependsOn("jar") + + classpath = jacocoAnt + + jar = project.tasks.named("jar").flatMap { it.archiveFile } + + dir = project.layout.buildDirectory.dir("instrumented") + } + } + + project.extensions.configure("publishing") { + repositories { + maven { + name = RepositoryDescriptor.INTEGRATION_REPO_NAME + url = project.uri("file://$repo") + } + } + + publications { + create(PublicationDescriptor.INTEGRATION_PUBLICATION_NAME) { + from(project.components["java"]) + + if (jacocoAnt != null) { + artifacts.clear() + + artifact(project.layout.buildDirectory.file("instrumented/${project.name}-${project.version}.jar")) { + builtBy(project.tasks.named("instrument")) + } + } + } + } + } + + tasks.named("test") { + dependsOn("${project.path}:publishIntegrationPublicationToIntegrationRepository") + } + + project.extensions.findByType( + GradlePluginDevelopmentExtension::class.java + )?.plugins?.forEach { plugin -> + val name = "publish" + plugin.name.capitalize() + "PluginMarkerMavenPublicationToIntegrationRepository" + + tasks.named("test") { + dependsOn("${project.path}:$name") + } + } + + if (jacocoAnt != null) { + project.tasks.named("generateMetadataFileForIntegrationPublication") { + enabled = false + } + } + + project.publishOnlyIf { publication, repository -> + if (publication == PublicationDescriptor.INTEGRATION) { + repository == RepositoryDescriptor.INTEGRATION + } else if (repository == RepositoryDescriptor.INTEGRATION) { + publication.isPlugin() + } else { + true + } + } +} diff --git a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/OfflineInstrumentation.kt b/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/OfflineInstrumentation.kt deleted file mode 100644 index d3a9029..0000000 --- a/shared-build-logic/src/main/kotlin/com/toasttab/gradle/testkit/shared/OfflineInstrumentation.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2024 Toast Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.toasttab.gradle.testkit.shared - -import org.gradle.api.Project -import org.gradle.api.artifacts.result.ArtifactResult -import org.gradle.api.plugins.JavaPlugin -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.testing.Test -import org.gradle.jvm.tasks.Jar -import org.gradle.kotlin.dsl.dependencies -import org.gradle.kotlin.dsl.named -import org.gradle.kotlin.dsl.register -import org.gradle.testing.jacoco.plugins.JacocoPlugin -import java.io.File - -private const val COPY_LOCAL_JARS_TASK = "copyLocalJars" -private const val INSTRUMENT_LOCAL_JARS_TASK = "instrumentLocalJars" - -private fun Project.instrumentedDir() = layout.buildDirectory.dir("instrumented-local-jars") -private fun Project.localJarsDir() = layout.buildDirectory.dir("local-jars") - -private fun Project.jacocoAgentRuntime() = zipTree(configurations.getAt(JacocoPlugin.AGENT_CONFIGURATION_NAME).asPath).filter { - it.name == "jacocoagent.jar" -}.singleFile - -fun Project.configureInstrumentation(coverageArtifact: Any) { - tasks.register(COPY_LOCAL_JARS_TASK) { - artifacts = runtimeArtifacts() - - jar = tasks.named(JavaPlugin.JAR_TASK_NAME) - - dir = localJarsDir() - } - - tasks.register(INSTRUMENT_LOCAL_JARS_TASK) { - dependsOn(COPY_LOCAL_JARS_TASK) - - classpath = configurations.getAt(JacocoPlugin.ANT_CONFIGURATION_NAME) - - jars = localJarsDir() - dir = instrumentedDir() - } - - val coverage = configurations.create("_coverage_plugin_") - dependencies { - add(coverage.name, coverageArtifact) - } - - tasks.named(JavaPlugin.TEST_TASK_NAME) { - dependsOn(INSTRUMENT_LOCAL_JARS_TASK) - - val runtimeArtifacts = runtimeArtifacts() - - inputs.files(runtimeArtifacts.artifactFiles).withPropertyName("plugin-artifacts").withPathSensitivity( - PathSensitivity.RELATIVE - ) - - inputs.files(coverage).withPropertyName("coverage-artifacts").withPathSensitivity( - PathSensitivity.RELATIVE - ) - - inputs.dir(instrumentedDir()).withPropertyName("instrumented-artifacts") - .withPathSensitivity(PathSensitivity.RELATIVE) - - systemProperty("testkit-plugin-instrumented-jars", instrumentedDir().get().asFile.path) - systemProperty("testkit-plugin-external-jars", - runtimeArtifacts.filter(ArtifactResult::isExternalPluginDependency) - .joinToString(separator = File.pathSeparator) { - it.file.path - }) - systemProperty("testkit-coverage-jars", coverage.asPath) - systemProperty("testkit-plugin-jacoco-jar", jacocoAgentRuntime().path) - } -} diff --git a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt index 1bdd690..5e6ad9e 100644 --- a/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt +++ b/testkit-plugin/src/main/kotlin/com/toasttab/gradle/testkit/TestkitPlugin.kt @@ -15,7 +15,8 @@ package com.toasttab.gradle.testkit -import com.toasttab.gradle.testkit.shared.configureInstrumentation +import com.toasttab.gradle.testkit.shared.configureIntegrationPublishing +import com.toasttab.gradle.testkit.shared.integrationRepo import org.apache.tools.ant.filters.ReplaceTokens import org.gradle.api.Plugin import org.gradle.api.Project @@ -44,7 +45,15 @@ class TestkitPlugin @Inject constructor( into(testProjectDir) if (extension.replaceTokens.isNotEmpty()) { - filter(mapOf("tokens" to extension.replaceTokens)) + filter( + mapOf( + "tokens" to mapOf( + "TESTKIT_PLUGIN_VERSION" to BuildConfig.VERSION, + "TESTKIT_INTEGRATION_REPO" to project.integrationRepo, + "VERSION" to "${project.version}" + ) + extension.replaceTokens + ) + ) } } @@ -62,6 +71,7 @@ class TestkitPlugin @Inject constructor( systemProperty("testkit-coverage-output", "${destfile.get()}") systemProperty("testkit-projects", "${testProjectDir.get()}") + systemProperty("testkit-integration-repo", project.integrationRepo) } project.pluginManager.withPlugin("jacoco") { @@ -73,7 +83,7 @@ class TestkitPlugin @Inject constructor( } } - project.configureInstrumentation("com.toasttab.gradle.testkit:coverage-plugin:${BuildConfig.VERSION}") + project.configureIntegrationPublishing() project.tasks.named("jacocoTestReport") { // add the TestKit jacoco file to the local jacoco report