diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 91c9048..6065d28 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -4,4 +4,5 @@ plugins { dependencies { implementation(libs.kotlinGradlePlugin) + implementation(libs.deltaCoveragePlugin) } diff --git a/build-logic/src/main/kotlin/buildlogic.delta-coverage-conventions.gradle.kts b/build-logic/src/main/kotlin/buildlogic.delta-coverage-conventions.gradle.kts new file mode 100644 index 0000000..e8f9c43 --- /dev/null +++ b/build-logic/src/main/kotlin/buildlogic.delta-coverage-conventions.gradle.kts @@ -0,0 +1,27 @@ +import io.github.surpsg.deltacoverage.CoverageEngine + +plugins { + base + id("io.github.surpsg.delta-coverage") +} + +deltaCoverageReport { + coverage.engine = CoverageEngine.INTELLIJ + + diffSource.byGit { + diffBase = project.properties["diffBase"]?.toString() ?: "refs/remotes/origin/main" + useNativeGit = true + } + + reports { + html = true + xml = true + console = true + } + + violationRules.failIfCoverageLessThan(0.9) +} + +tasks.named("gitDiff") { + outputs.upToDateWhen { false } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e5a209c..91387f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("buildlogic.java-common-conventions") + id("buildlogic.delta-coverage-conventions") } allprojects { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afe4a60..11a1112 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,15 @@ [versions] kotlinVersion = "1.9.22" +junitVersion = "5.10.0" +deltaCoverageVer = "2.3.0" [libraries] +# Kotlin +kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinVersion" } kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinVersion" } +deltaCoveragePlugin = { module = "io.github.surpsg:delta-coverage-gradle", version.ref = "deltaCoverageVer" } + +# Testing +junit = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitVersion" } [plugins] diff --git a/lib/build.gradle.kts b/junit-engine/build.gradle.kts similarity index 63% rename from lib/build.gradle.kts rename to junit-engine/build.gradle.kts index 04f3df2..46493b7 100644 --- a/lib/build.gradle.kts +++ b/junit-engine/build.gradle.kts @@ -2,6 +2,12 @@ plugins { id("buildlogic.kotlin-common-conventions") } +dependencies { + implementation(libs.junit) + implementation(libs.kotlinReflect) + implementation(gradleTestKit()) +} + @Suppress("UnstableApiUsage") testing { suites { diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt new file mode 100644 index 0000000..9d273a0 --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt @@ -0,0 +1,84 @@ +package io.github.gwkit.gradleprobe.gradle.junit +import io.github.gwkit.gradleprobe.gradle.resources.RestorableFile +import io.github.gwkit.gradleprobe.gradle.resources.copyDirFromResources +import io.github.gwkit.gradleprobe.gradle.resources.toUnixAbsolutePath +import io.github.gwkit.gradleprobe.gradle.runner.buildGradleRunner +import io.github.gwkit.gradleprobe.lib.reflect.injectProperty +import org.gradle.testkit.runner.GradleRunner +import org.gradle.util.GradleVersion as GradleToolVersion +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.api.extension.TestInstancePostProcessor +import java.io.File +import java.nio.file.Files +import java.util.UUID +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation + +class GradlePluginTestExtension : TestInstancePostProcessor { + + override fun postProcessTestInstance(testInstance: Any, context: ExtensionContext) { + val testClass: KClass = testInstance::class + + if (testClass.hasAnnotation()) { + return + } + + val gradleRunnerTest: GradleRunnerTest = testClass.findAnnotation() + ?: error("Test class ${testInstance::class.qualifiedName} must be annotated with @GradleRunnerTest") + + val gradleVersion: GradleVersion? = testClass.findAnnotation() + + val rootProjectDir: File = copyResourceProjectToTempDir( + testClass, + gradleRunnerTest + ) + + with(testInstance) { + injectProperty(rootProjectDir) + injectProperty( + buildGradleRunner( + rootProjectDir, + gradleVersion?.version ?: GradleToolVersion.current().version + ) + ) + injectProperty { + val tempTestFile: File = Files.createTempDirectory(TEST_DIR_PREFIX).toFile() + val fileToBeRestored: File = resolveExistingFile(rootProjectDir, relativePath) + val originCopy: File = tempTestFile.resolve(UUID.randomUUID().toString()) + fileToBeRestored.copyTo(originCopy) + RestorableFile(originFileCopy = originCopy, file = fileToBeRestored) + } + injectProperty { + resolveExistingFile(rootProjectDir, relativePath) + } + injectProperty { + resolveExistingFile(rootProjectDir, relativePath).toUnixAbsolutePath() + } + } + } + + private fun copyResourceProjectToTempDir( + testClass: KClass, + gradleRunnerTest: GradleRunnerTest, + ): File { + val tempTestFile: File = Files.createTempDirectory(TEST_DIR_PREFIX).toFile() + + return tempTestFile.copyDirFromResources( + testClass, + gradleRunnerTest.resourceProjectDir + ) + } + + private fun resolveExistingFile(rootProjectDir: File, relativePath: String): File { + return rootProjectDir.resolve(relativePath) + .takeIf { it.exists() } + ?: error("File $relativePath not found") + } + + companion object { + private const val TEST_DIR_PREFIX = "gradle-runner-test" + } + +} \ No newline at end of file diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradleRunnerTest.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradleRunnerTest.kt new file mode 100644 index 0000000..1775fc9 --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradleRunnerTest.kt @@ -0,0 +1,92 @@ +package io.github.gwkit.gradleprobe.gradle.junit + +/** + * Prepares a test class for running a Gradle plugin test. + * The extension finds the test project in resources and copies it to a temporary directory. + * + * @param resourceProjectDir The relative path to the test project located in test resources. + * + * @see GradlePluginTestExtension + */ +@MustBeDocumented +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class GradleRunnerTest( + val resourceProjectDir: String, +) + +/** + * Specifies the Gradle version to use for the test. + * The extension will use the specified Gradle version to run the test. + * + * @param version The Gradle version to use. + * + * @see GradlePluginTestExtension + */ +@MustBeDocumented +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class GradleVersion( + val version: String +) + +/** + * Injects a GradleRunner instance into test class property. + * The property must be of type GradleRunner and must be lateinit. + * ``` + * @GradlePluginTest("testProject") + * class MyTest { + * + * @GradleRunnerInstance + * lateinit var gradleRunner: GradleRunner + * + * } + * ``` + */ +@MustBeDocumented +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class GradleRunnerInstance + +/** + * Injects the root project directory into test class property. + * The property must be of type File and must be lateinit. + * ``` + * @GradlePluginTest("testProject") + * class MyTest { + * + * @RootProjectDir + * lateinit var rootProjectDir: File + * + * } + * ``` + */ +@MustBeDocumented +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class RootProjectDir + +/** + * Resolves and injects a project file into test class property. The file must exist. + * The property must be of type [java.io.File] or [String] and must be lateinit. + * ``` + * @GradlePluginTest("testProject") + * class MyTest { + * + * @ProjectFile("build.gradle.kts") + * lateinit var buildFile: File + * + * @ProjectFile("build.gradle.kts") + * lateinit var buildFilePath: String + * + * } + * ``` + * @param relativePath The relative path to the project file. + * Useful when the test class has `TestInstance.Lifecycle.PER_CLASS` lifecycle. + */ +@MustBeDocumented +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class ProjectFile( + val relativePath: String, +) \ No newline at end of file diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/ResourceFileOperations.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/ResourceFileOperations.kt new file mode 100644 index 0000000..8f6d6ff --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/ResourceFileOperations.kt @@ -0,0 +1,25 @@ +package io.github.gwkit.gradleprobe.gradle.resources + +import java.io.File +import java.net.URL +import kotlin.reflect.KClass + +internal fun KClass.getResourceFile(filePath: String): File { + val url: URL = java.classLoader + .getResource(filePath) + ?: error("Resource file $filePath not found") + return File(url.file) +} + +internal fun File.copyDirFromResources( + sourceClass: KClass, + dirToCopy: String, + destDir: String = dirToCopy, +): File { + val target: File = resolve(destDir) + sourceClass.getResourceFile(dirToCopy) + .copyRecursively(target, true) + return target +} + +internal fun File.toUnixAbsolutePath(): String = absolutePath.replace("\\", "/") diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/RestorableFile.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/RestorableFile.kt new file mode 100644 index 0000000..6fa3ff4 --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/resources/RestorableFile.kt @@ -0,0 +1,20 @@ +package io.github.gwkit.gradleprobe.gradle.resources + +import java.io.File +import java.io.IOException + +/** + * Represents a file that can be restored to its original content. + * The file is created by copying the content of the origin file. + * + * @param originFileCopy The origin file copy. + * @param file The file to restore. + * + */ +class RestorableFile(private val originFileCopy: File, val file: File) { + + @Throws(IOException::class) + fun restoreOriginContent() { + originFileCopy.copyTo(file, overwrite = true) + } +} diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/runner/GradleRunnerTestUtil.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/runner/GradleRunnerTestUtil.kt new file mode 100644 index 0000000..16478a2 --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/gradle/runner/GradleRunnerTestUtil.kt @@ -0,0 +1,34 @@ +package io.github.gwkit.gradleprobe.gradle.runner + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.util.GradleVersion +import java.io.File +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString + +val GRADLE_HOME: String + get() { + val userHome: String = System.getProperty("user.home") ?: error("Cannot obtain 'user.home'.") + return Path(userHome, ".gradle").absolutePathString() + } + +fun buildGradleRunner( + projectRoot: File, + gradleVersion: String = GradleVersion.current().version +): GradleRunner { + return GradleRunner.create() + .withPluginClasspath() + .withProjectDir(projectRoot) + .withTestKitDir( + projectRoot.resolve(GRADLE_HOME).apply { mkdirs() } + ) + .withGradleVersion(gradleVersion) + .apply { + // gradle testkit jacoco support + javaClass.classLoader.getResourceAsStream("testkit-gradle.properties")?.use { inputStream -> + File(projectDir, "gradle.properties").outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } +} diff --git a/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/lib/reflect/InjectProperty.kt b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/lib/reflect/InjectProperty.kt new file mode 100644 index 0000000..02df5bb --- /dev/null +++ b/junit-engine/src/main/kotlin/io/github/gwkit/gradleprobe/lib/reflect/InjectProperty.kt @@ -0,0 +1,43 @@ +package io.github.gwkit.gradleprobe.lib.reflect + +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.jvm.isAccessible + +internal inline fun Any.injectProperty( + valueToInject: V, +) { + val thisInstance = this + thisInstance::class + .memberProperties + .asSequence() + .filter { property -> property.hasAnnotation() } + .filter { property -> property.returnType.classifier == V::class } + .filter { property -> property.isLateinit } + .onEach { property -> property.isAccessible = true } + .mapNotNull { property -> property as? KMutableProperty<*> } + .forEach { property -> property.setter.call(thisInstance, valueToInject) } +} + +internal inline fun Any.injectProperty( + crossinline valueProvider: A.() -> V, +) { + val thisInstance = this + thisInstance::class + .memberProperties + .asSequence() + .mapNotNull { property -> + property.findAnnotation()?.let { annotation -> annotation to property } + } + .filter { (_, property) -> property.returnType.classifier == V::class } + .filter { (_, property) -> property.isLateinit } + .onEach { (_, property) -> property.isAccessible = true } + .mapNotNull { (annotation, property) -> + (property as? KMutableProperty<*>)?.let { mutableProperty -> + annotation.valueProvider() to mutableProperty + } + } + .forEach { (valueToInject, property) -> property.setter.call(thisInstance, valueToInject) } +} diff --git a/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt b/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt new file mode 100644 index 0000000..0e846a7 --- /dev/null +++ b/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginExtension.kt @@ -0,0 +1,4 @@ +package io.github.gwkit.gradleprobe.gradle.junit + +class GradlePluginTestExtension : TestInstancePostProcessor { +} \ No newline at end of file diff --git a/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginTest.kt b/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginTest.kt new file mode 100644 index 0000000..f80d492 --- /dev/null +++ b/junit-engine/src/testFixtures/kotlin/io/github/gwkit/gradleprobe/gradle/junit/GradlePluginTest.kt @@ -0,0 +1,16 @@ +package io.github.gwkit.gradleprobe.gradle.junit + + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class GradlePluginTest( + // resource project directory + // kts/groovy (is needed?) + // gradle version + val resourceProjectDir: String, + val gradleVersion: String = DEFAULT_GRADLE_VERSION, +) { + companion object { + const val DEFAULT_GRADLE_VERSION = "8.7.0" + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 39f6c33..4a61417 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,5 +9,13 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + rootProject.name = "gradle-probe" -include("lib") +include("junit-engine")