From 4b15dbefbb5c97481b37d5ade95e7bcb842c9e7d Mon Sep 17 00:00:00 2001 From: Dexton Anderson Date: Mon, 23 Sep 2024 17:10:19 -0500 Subject: [PATCH] Start adding CI features --- build.gradle.kts | 2 + .../us/ihmc/ci/AllocationInstrumenter.kt | 6 + .../us/ihmc/ci/IHMCCICategoriesExtension.kt | 45 ++ src/main/kotlin/us/ihmc/ci/IHMCCILogTools.kt | 48 +++ src/main/kotlin/us/ihmc/ci/IHMCCIPlugin.kt | 393 ++++++++++++++++++ src/main/kotlin/us/ihmc/ci/JUnitExtension.kt | 10 + src/main/kotlin/us/ihmc/ci/TagParser.kt | 139 +++++++ 7 files changed, 643 insertions(+) create mode 100644 src/main/kotlin/us/ihmc/ci/AllocationInstrumenter.kt create mode 100644 src/main/kotlin/us/ihmc/ci/IHMCCICategoriesExtension.kt create mode 100644 src/main/kotlin/us/ihmc/ci/IHMCCILogTools.kt create mode 100644 src/main/kotlin/us/ihmc/ci/IHMCCIPlugin.kt create mode 100644 src/main/kotlin/us/ihmc/ci/JUnitExtension.kt create mode 100644 src/main/kotlin/us/ihmc/ci/TagParser.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6b80642..44ada9e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { api("com.hierynomus:sshj:0.38.0") testApi("org.junit.jupiter:junit-jupiter-api:5.10.3") + testApi("org.junit.platform:junit-platform-console:1.10.3") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.3") } diff --git a/src/main/kotlin/us/ihmc/ci/AllocationInstrumenter.kt b/src/main/kotlin/us/ihmc/ci/AllocationInstrumenter.kt new file mode 100644 index 0000000..5f58ea7 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/AllocationInstrumenter.kt @@ -0,0 +1,6 @@ +package us.ihmc.ci + +class AllocationInstrumenter(val version: String) +{ + fun instrumenter(): String = "com.google.code.java-allocation-instrumenter:java-allocation-instrumenter:$version" +} \ No newline at end of file diff --git a/src/main/kotlin/us/ihmc/ci/IHMCCICategoriesExtension.kt b/src/main/kotlin/us/ihmc/ci/IHMCCICategoriesExtension.kt new file mode 100644 index 0000000..b38efd6 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/IHMCCICategoriesExtension.kt @@ -0,0 +1,45 @@ +package us.ihmc.ci + +import org.gradle.api.Project + +val ALLOCATION_AGENT_KEY = "allocationAgent" + +class IHMCCICategory(val name: String) +{ + var forkEvery = 0 // no limit + var maxParallelForks = 1 // careful, cost of spawning JVMs is high + var junit5ParallelEnabled = false // doesn't work right now with Gradle's test runner. See: https://github.com/gradle/gradle/issues/6453 + var junit5ParallelStrategy = "fixed" + var junit5ParallelFixedParallelism = 1 + val excludeTags = hashSetOf() + val includeTags = hashSetOf() + val jvmProperties = hashMapOf() + val jvmArguments = hashSetOf() + var minHeapSizeGB = 1 + var maxHeapSizeGB = 4 + var enableAssertions = true + var defaultTimeout = 1200 // 20 minutes + var testTaskTimeout = 1800 // 30 minutes + var doFirst: () -> Unit = {} // run user code when this category is selected +} + +open class IHMCCICategoriesExtension(private val project: Project) +{ + val categories = hashMapOf() + + fun configure(name: String, configuration: IHMCCICategory.() -> Unit) + { + configuration.invoke(configure(name)) + } + + fun configure(name: String): IHMCCICategory + { + val category = categories.getOrPut(name, { IHMCCICategory(name) }) + if (name != "all" && name != "fast") // all require no includes or excludes, fast will be configured later + { + category.includeTags += name // by default, include tags of the category name + } + + return category + } +} \ No newline at end of file diff --git a/src/main/kotlin/us/ihmc/ci/IHMCCILogTools.kt b/src/main/kotlin/us/ihmc/ci/IHMCCILogTools.kt new file mode 100644 index 0000000..a30b7d5 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/IHMCCILogTools.kt @@ -0,0 +1,48 @@ +package us.ihmc.ci + +import org.gradle.api.GradleException +import org.gradle.api.logging.Logger + +class IHMCCILogTools(val logger: Logger) +{ + fun quiet(message: Any) + { + logger.quiet(ihmcBuildMessage(message)) + } + + fun info(message: Any) + { + logger.info(ihmcBuildMessage(message)) + } + + fun warn(message: Any) + { + logger.warn(ihmcBuildMessage(message)) + } + + fun error(message: Any) + { + logger.error(ihmcBuildMessage(message)) + } + + fun debug(message: Any) + { + logger.debug(ihmcBuildMessage(message)) + } + + fun trace(trace: Any) + { + logger.trace(trace.toString()) + } + + fun crash(message: Any) + { + error(message) + throw GradleException("[ihmc-ci] " + message as String) + } + + private fun ihmcBuildMessage(message: Any): String + { + return "[ihmc-ci] " + message + } +} \ No newline at end of file diff --git a/src/main/kotlin/us/ihmc/ci/IHMCCIPlugin.kt b/src/main/kotlin/us/ihmc/ci/IHMCCIPlugin.kt new file mode 100644 index 0000000..38e5f65 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/IHMCCIPlugin.kt @@ -0,0 +1,393 @@ +package us.ihmc.ci; + +import org.gradle.api.GradleException +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.plugins.JavaPluginConvention +import org.gradle.api.tasks.testing.Test +import org.gradle.api.tasks.testing.logging.TestLogEvent +import java.io.File +import java.time.Duration + +lateinit var LogTools: IHMCCILogTools + +class IHMCCIPlugin : Plugin +{ + val JUNIT_VERSION = "5.10.3" + val PLATFORM_VERSION = "1.10.3" + val ALLOCATION_INSTRUMENTER_VERSION = "3.3.0" + + lateinit var project: Project + var cpuThreads = 8 + var category: String = "fast" + object Unset + var minHeapSizeGBOverride: Any = Unset + var maxHeapSizeGBOverride: Any = Unset + var forkEveryOverride: Any = Unset + var maxParallelForksOverride: Any = Unset + var enableAssertionsOverride: Any = Unset + var defaultTimeoutOverride: Any = Unset + var testTaskTimeoutOverride: Any = Unset + var allocationRecordingOverride: Any = Unset + lateinit var categoriesExtension: IHMCCICategoriesExtension + var allocationJVMArg: String? = null + val apiConfigurationName = "api" + val runtimeConfigurationName = "runtimeOnly" + val addedDependenciesMap = HashMap() + var configuredTestTasks = HashMap() + val testProjects = lazy { + val testProjects = arrayListOf() + for (allproject in project.allprojects) + { + if (allproject.name.endsWith("-test")) + { + testProjects += allproject + } + } + testProjects + } + val testsToTagsMap = lazy { + val map = hashMapOf>() + testProjects.value.forEach { + TagParser.parseForTags(it, map) + } + map + } + private val junit = JUnitExtension(JUNIT_VERSION, PLATFORM_VERSION) + private val allocation = AllocationInstrumenter(ALLOCATION_INSTRUMENTER_VERSION) + + override fun apply(project: Project) + { + this.project = project + LogTools = IHMCCILogTools(project.logger) + + loadProperties() + categoriesExtension = project.extensions.create("categories", IHMCCICategoriesExtension::class.java, project) + project.extensions.add("junit", junit) + project.extensions.add("allocation", allocation) + + // These things must be configured later in the build lifecycle. + // Here, we are notified when any task is added to the build. + // This happens a lot, so must check if requirements are met + // and gate it with a boolean. + project.tasks.whenTaskAdded { + for (testProject in testProjects.value) + { + addDependencies(testProject, apiConfigurationName, runtimeConfigurationName) + configureTestTask(testProject) + } + if (!containsIHMCTestMultiProject(project)) + { + addDependencies(project, "testImplementation", "testRuntimeOnly") + configureTestTask(project) + } + + var allHaveCompileJava = true + testProjects.value.forEach { testProject -> + allHaveCompileJava = allHaveCompileJava && testProject.tasks.findByPath("compileJava") != null + } + } + } + + private fun addDependencies(project: Project, apiConfigurationName: String, runtimeConfigurationName: String) + { + addedDependenciesMap.computeIfAbsent("${project.name}:$apiConfigurationName") { false } + addedDependenciesMap.computeIfAbsent("${project.name}:$runtimeConfigurationName") { false } + + // add runtime dependencies + if (!addedDependenciesMap["${project.name}:$runtimeConfigurationName"]!! && configurationExists(project, runtimeConfigurationName)) + { + addedDependenciesMap["${project.name}:$runtimeConfigurationName"] = true + LogTools.info("Adding JUnit 5 dependencies to $runtimeConfigurationName in ${project.name}") + project.dependencies.add(runtimeConfigurationName, junit.jupiterEngine()) + } + + // add api dependencies + if (!addedDependenciesMap["${project.name}:$apiConfigurationName"]!! && configurationExists(project, apiConfigurationName)) + { + addedDependenciesMap["${project.name}:$apiConfigurationName"] = true + LogTools.info("Adding JUnit 5 dependencies to $apiConfigurationName in ${project.name}") + project.dependencies.add(apiConfigurationName, junit.jupiterApi()) + project.dependencies.add(apiConfigurationName, junit.platformCommons()) + project.dependencies.add(apiConfigurationName, junit.platformLauncher()) + + if (category == "allocation") // help out users trying to run allocation tests + { + LogTools.info("Adding allocation intrumenter dependency to $apiConfigurationName in ${project.name}") + project.dependencies.add(apiConfigurationName, allocation.instrumenter()) + } + } + } + + private fun configurationExists(project: Project, name: String): Boolean + { + for (configuration in project.configurations) + { + if (configuration.name == name) + { + return true + } + } + return false + } + + fun configureTestTask(project: Project) + { + configuredTestTasks.computeIfAbsent(project.name) { false } + + if (!configuredTestTasks[project.name]!! && project.tasks.findByName("test") != null) + { + configuredTestTasks[project.name] = true + val addPhonyTestXmlTask = addPhonyTestXmlTask(project) + project.tasks.named("test", Test::class.java) { + // create a default category if not found + val categoryConfig = postProcessCategoryConfig() + applyCategoryConfigToGradleTest(this, categoryConfig) + + doFirst { + val test: Test = this as Test + test.setForkEvery(categoryConfig.forkEvery.toLong()) + test.maxParallelForks = categoryConfig.maxParallelForks + this.project.properties["runningOnCIServer"].run { + if (this != null) + test.systemProperties["runningOnCIServer"] = toString() + } + for (jvmProp in categoryConfig.jvmProperties) + { + test.systemProperties[jvmProp.key] = jvmProp.value + } + test.systemProperties["junit.jupiter.execution.timeout.default"] = categoryConfig.defaultTimeout + test.timeout.set(Duration.ofSeconds(categoryConfig.testTaskTimeout.toLong())) + + if (categoryConfig.junit5ParallelEnabled) + { + test.systemProperties["junit.jupiter.execution.parallel.enabled"] = "true" + test.systemProperties["junit.jupiter.execution.parallel.config.strategy"] = categoryConfig.junit5ParallelStrategy + test.systemProperties["junit.jupiter.execution.parallel.config.fixed.parallelism"] = categoryConfig.junit5ParallelFixedParallelism + } + + val java = project.convention.getPlugin(JavaPluginConvention::class.java) + val resourcesDir = java.sourceSets.getByName("main").output.resourcesDir + LogTools.info("Passing to JVM: -Dresource.dir=$resourcesDir") + test.systemProperties["resource.dir"] = resourcesDir + + for (jvmArg in categoryConfig.jvmArguments) + { + if (jvmArg == ALLOCATION_AGENT_KEY) + { + test.jvmArgs(findAllocationJVMArg()) + } + else + { + test.jvmArgs(jvmArg) + } + } + if (categoryConfig.enableAssertions) + { + LogTools.info("Assertions enabled. Adding JVM arg: -ea") + test.enableAssertions = true + } + else + { + LogTools.info("Assertions disabled") + test.enableAssertions = false + } + + test.minHeapSize = "${categoryConfig.minHeapSizeGB}g" + test.maxHeapSize = "${categoryConfig.maxHeapSizeGB}g" + + test.testLogging.info.events = setOf(TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_ERROR, + TestLogEvent.STANDARD_OUT) + + LogTools.info("test.forkEvery = ${test.forkEvery}") + LogTools.info("test.maxParallelForks = ${test.maxParallelForks}") + LogTools.info("test.systemProperties = ${test.systemProperties}") + LogTools.info("test.allJvmArgs = ${test.allJvmArgs}") + LogTools.info("test.minHeapSize = ${test.minHeapSize}") + LogTools.info("test.maxHeapSize = ${test.maxHeapSize}") + + // List tests to be run + LogTools.quiet("Tests to be run:") + testsToTagsMap.value.forEach { entry -> + if ((category == "fast" && entry.value.isEmpty()) || entry.value.contains(category)) + { + LogTools.quiet(entry.key + " " + entry.value) + } + } + } + + finalizedBy(addPhonyTestXmlTask) + } + } + } + + fun applyCategoryConfigToGradleTest(test: Test, categoryConfig: IHMCCICategory) + { + categoryConfig.doFirst.invoke() + + test.useJUnitPlatform { + for (tag in categoryConfig.includeTags) + { + this.includeTags(tag) + } + for (tag in categoryConfig.excludeTags) + { + this.excludeTags(tag) + } + // If the "fast" category includes nothing, this excludes all tags included by other + // categories, which makes it run only untagged tests and tests that would not be run + // if the user were to run all defined catagories. This is both a safety feature, + // and the expected functionality of the "fast" category, historically at IHMC. + if (categoryConfig.name == "fast" && categoryConfig.includeTags.isEmpty()) + { + for (definedCategory in categoriesExtension.categories) + { + for (tag in definedCategory.value.includeTags) + { + if (tag != "fast") // this allows @Tag("fast") to be used + { + this.excludeTags(tag) + } + } + } + } + } + } + + fun postProcessCategoryConfig(): IHMCCICategory + { + val categoryConfig = categoriesExtension.configure(category) + + if (categoryConfig.name == "fast") // fast runs all "untagged" tests, so exclude all found tags + { + categoryConfig.includeTags.clear() + // https://github.com/junit-team/junit5/issues/1679 + categoryConfig.includeTags.add("fast | none()") + } + minHeapSizeGBOverride.run { if (this is Int) categoryConfig.minHeapSizeGB = this } + maxHeapSizeGBOverride.run { if (this is Int) categoryConfig.maxHeapSizeGB = this } + forkEveryOverride.run { if (this is Int) categoryConfig.forkEvery = this } + maxParallelForksOverride.run { if (this is Int) categoryConfig.maxParallelForks = this } + enableAssertionsOverride.run { if (this is Boolean) categoryConfig.enableAssertions = this } + defaultTimeoutOverride.run { if (this is Int) categoryConfig.defaultTimeout = this } + testTaskTimeoutOverride.run { if (this is Int) categoryConfig.testTaskTimeout = this } + allocationRecordingOverride.run { if (this is Boolean && this) categoryConfig.jvmArguments += ALLOCATION_AGENT_KEY } + + LogTools.info("${categoryConfig.name}.forkEvery = ${categoryConfig.forkEvery}") + LogTools.info("${categoryConfig.name}.maxParallelForks = ${categoryConfig.maxParallelForks}") + LogTools.info("${categoryConfig.name}.excludeTags = ${categoryConfig.excludeTags}") + LogTools.info("${categoryConfig.name}.includeTags = ${categoryConfig.includeTags}") + LogTools.info("${categoryConfig.name}.jvmProperties = ${categoryConfig.jvmProperties}") + LogTools.info("${categoryConfig.name}.jvmArguments = ${categoryConfig.jvmArguments}") + LogTools.info("${categoryConfig.name}.minHeapSizeGB = ${categoryConfig.minHeapSizeGB}") + LogTools.info("${categoryConfig.name}.maxHeapSizeGB = ${categoryConfig.maxHeapSizeGB}") + LogTools.info("${categoryConfig.name}.enableAssertions = ${categoryConfig.enableAssertions}") + LogTools.info("${categoryConfig.name}.defaultTimeout = ${categoryConfig.defaultTimeout}") + LogTools.info("${categoryConfig.name}.testTaskTimeout = ${categoryConfig.testTaskTimeout}") + LogTools.info("${categoryConfig.name}.allocationRecording = ${categoryConfig.jvmArguments}") + + return categoryConfig + } + + fun addPhonyTestXmlTask(anyproject: Project): Task? + { + return anyproject.tasks.create("addPhonyTestXml") { + this.doLast { + var testsFound = false + for (path in anyproject.rootDir.walkBottomUp()) + { + if (path.toPath().toAbsolutePath().toString().matches(Regex(".*/test-results/test/.*\\.xml"))) + { + LogTools.info("Found test file: $path") + testsFound = true + break + } + } + if (!testsFound) + createNoTestsFoundXml(anyproject, anyproject.buildDir.resolve("test-results/test")) + } + } + } + + fun createNoTestsFoundXml(testProject: Project, testDir: File) + { + testProject.mkdir(testDir) + val noTestsFoundFile = testDir.resolve("TEST-us.ihmc.NoTestsFoundTest.xml") + LogTools.info("No tests found. Writing $noTestsFoundFile") + noTestsFoundFile.writeText( + "" + + "" + + "" + + "" + + "This is a phony test to make CI builds pass when a project does not contain any tests." + + "" + + "") + } + + fun findAllocationJVMArg(): String + { + if (allocationJVMArg == null) // search only once + { + for (testProject in testProjects.value) + { + testProject.configurations.getByName("runtimeClasspath").files.forEach { + if (it.name.contains("java-allocation-instrumenter")) + { + allocationJVMArg = "-javaagent:" + it.getAbsolutePath() + LogTools.info("Found allocation JVM arg: $allocationJVMArg") + } + } + } + if (allocationJVMArg == null) // error out, because user needs to add it + { + throw GradleException("[ihmc-ci] Cannot find `java-allocation-instrumenter` on test classpath. Please add it to your test dependencies!") + } + } + + return allocationJVMArg!! + } + + fun loadProperties() + { + project.properties["cpuThreads"].run { if (this != null) cpuThreads = (this as String).toInt() } + project.properties["category"].run { if (this != null) category = (this as String).trim().toLowerCase() } + project.properties["minHeapSizeGB"].run { if (this != null) minHeapSizeGBOverride = (this as String).toInt() } + project.properties["maxHeapSizeGB"].run { if (this != null) maxHeapSizeGBOverride = (this as String).toInt() } + project.properties["forkEvery"].run { if (this != null) forkEveryOverride = (this as String).toInt() } + project.properties["maxParallelForks"].run { if (this != null) maxParallelForksOverride = (this as String).toInt() } + project.properties["enableAssertions"].run { if (this != null) enableAssertionsOverride = (this as String).toBoolean() } + project.properties["defaultTimeout"].run { if (this != null) defaultTimeoutOverride = (this as String).toInt() } + project.properties["testTaskTimeout"].run { if (this != null) testTaskTimeoutOverride = (this as String).toInt() } + project.properties["allocationRecording"].run { if (this != null) allocationRecordingOverride = (this as String).toBoolean() } + LogTools.info("cpuThreads = $cpuThreads") + LogTools.info("category = $category") + LogTools.info("minHeapSizeGB = ${unsetPrintFilter(minHeapSizeGBOverride)}") + LogTools.info("maxHeapSizeGB = ${unsetPrintFilter(maxHeapSizeGBOverride)}") + LogTools.info("forkEvery = ${unsetPrintFilter(forkEveryOverride)}") + LogTools.info("maxParallelForks = ${unsetPrintFilter(maxParallelForksOverride)}") + LogTools.info("enableAssertions = ${unsetPrintFilter(enableAssertionsOverride)}") + LogTools.info("defaultTimeout = ${unsetPrintFilter(defaultTimeoutOverride)}") + LogTools.info("testTaskTimeout = ${unsetPrintFilter(testTaskTimeoutOverride)}") + LogTools.info("allocationRecording = ${unsetPrintFilter(allocationRecordingOverride)}") + } + + private fun unsetPrintFilter(any: Any) = if (any is Unset) "Not set" else any + + fun containsIHMCTestMultiProject(project: Project): Boolean + { + for (allproject in project.allprojects) + { + if (allproject.name.endsWith("-test")) + { + return true + } + } + return false + } +} diff --git a/src/main/kotlin/us/ihmc/ci/JUnitExtension.kt b/src/main/kotlin/us/ihmc/ci/JUnitExtension.kt new file mode 100644 index 0000000..eea3d75 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/JUnitExtension.kt @@ -0,0 +1,10 @@ +package us.ihmc.ci + +class JUnitExtension(val jupiterVersion: String, + val platformVersion: String) +{ + fun jupiterApi(): String = "org.junit.jupiter:junit-jupiter-api:$jupiterVersion" + fun jupiterEngine(): String = "org.junit.jupiter:junit-jupiter-engine:$jupiterVersion" + fun platformCommons(): String = "org.junit.platform:junit-platform-commons:$platformVersion" + fun platformLauncher(): String = "org.junit.platform:junit-platform-launcher:$platformVersion" +} \ No newline at end of file diff --git a/src/main/kotlin/us/ihmc/ci/TagParser.kt b/src/main/kotlin/us/ihmc/ci/TagParser.kt new file mode 100644 index 0000000..e8f6f65 --- /dev/null +++ b/src/main/kotlin/us/ihmc/ci/TagParser.kt @@ -0,0 +1,139 @@ +package us.ihmc.ci + +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginConvention +import org.gradle.internal.impldep.org.junit.platform.engine.discovery.ClasspathRootSelector +import org.gradle.internal.impldep.org.junit.platform.engine.discovery.DiscoverySelectors +import org.gradle.internal.impldep.org.junit.platform.engine.support.descriptor.MethodSource +import org.gradle.internal.impldep.org.junit.platform.launcher.LauncherDiscoveryRequest +import org.gradle.internal.impldep.org.junit.platform.launcher.TestIdentifier +import org.gradle.internal.impldep.org.junit.platform.launcher.TestPlan +import org.gradle.internal.impldep.org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder +import org.gradle.internal.impldep.org.junit.platform.launcher.core.LauncherFactory +import java.io.File +import java.net.URL +import java.net.URLClassLoader +import java.nio.file.Path + +object TagParser +{ + /** + * Return map of fully qualified test names to tag names sourced + * from the JUnit 5 discovery engine itself. + */ + fun parseForTags(testProject: Project, testsToTagsMap: HashMap>) + { + LogTools.info("Discovering tests in $testProject") + + val contextClasspathUrls = arrayListOf() // all of the tests and dependencies + val selectorPaths = hashSetOf() // just the test classes in this project + assembleTestClasspath(testProject, contextClasspathUrls, selectorPaths) + LogTools.debug("Classpath entries: $contextClasspathUrls") + + val originalClassLoader = Thread.currentThread().contextClassLoader + val customClassLoader = URLClassLoader.newInstance(contextClasspathUrls.toTypedArray(), originalClassLoader) + lateinit var testPlan: TestPlan + try + { + Thread.currentThread().contextClassLoader = customClassLoader + debugContextClassLoader(customClassLoader) + + val launcher = LauncherFactory.create() + val builder = LauncherDiscoveryRequestBuilder.request() + builder.selectors(DiscoverySelectors.selectClasspathRoots(selectorPaths)) + builder.configurationParameters(emptyMap()) + val discoveryRequest = builder.build() + debugClasspathSelectors(discoveryRequest) + testPlan = launcher.discover(discoveryRequest) + recursiveBuildMap(testPlan.roots, testPlan, testsToTagsMap) + LogTools.debug("Contains tests: ${testPlan.containsTests()}") + } + finally + { + Thread.currentThread().contextClassLoader = originalClassLoader + } + } + + private fun recursiveBuildMap(set: Set, testPlan: TestPlan, testsToTagsMap: HashMap>) + { + set.forEach { testIdentifier -> + if (testIdentifier.isTest && testIdentifier.source.isPresent && testIdentifier.source.get() is MethodSource) + { + val methodSource = testIdentifier.source.get() as MethodSource + LogTools.debug("Test id: ${testIdentifier.displayName} tags: ${testIdentifier.tags} path: $methodSource") + val fullyQualifiedTestName = methodSource.className + "." + methodSource.methodName + if (!testsToTagsMap.containsKey(fullyQualifiedTestName)) + { + testsToTagsMap.put(fullyQualifiedTestName, hashSetOf()) + } + + testIdentifier.tags.forEach { testTag -> + testsToTagsMap[fullyQualifiedTestName]!!.add(testTag.name) + } + } + else + { + LogTools.debug("Test id: ${testIdentifier.displayName} tags: ${testIdentifier.tags} type: ${testIdentifier.type}") + } + + recursiveBuildMap(testPlan.getChildren(testIdentifier), testPlan, testsToTagsMap) + } + } + + /** + * This function gathers all the paths and JARs comprising the classpath of the test source set + * of the project this plugin is applied to. It is used to simulate conditions as if Gradle or + * JUnit was running those tests. + */ + private fun assembleTestClasspath(testProject: Project, contextClasspathUrls: ArrayList, selectorPaths: HashSet) + { + // TODO: + val java = testProject.convention.getPlugin(JavaPluginConvention::class.java) +// val java = testProject.convention.getPlugin(JavaLibraryPlugin::class.java) +// testProject.plugins. +// testProject.configurations.getByName("default").forEach { file -> + java.sourceSets.getByName("main").compileClasspath.forEach { file -> + addStuffToClasspath(file, contextClasspathUrls, selectorPaths) + } + java.sourceSets.getByName("main").runtimeClasspath.forEach { file -> + addStuffToClasspath(file, contextClasspathUrls, selectorPaths) + } + } + + private fun addStuffToClasspath(file: File, contextClasspathUrls: ArrayList, selectorPaths: HashSet) + { + val entryString = file.toString() + val uri = file.toURI() + val path = file.toPath() + if (entryString.endsWith(".jar")) + { + contextClasspathUrls.add(uri.toURL()) + } + else if (!entryString.endsWith("/")) + { + val fileWithSlash = File("$entryString/") // TODO: Is this necessary? + contextClasspathUrls.add(fileWithSlash.toURI().toURL()) + selectorPaths.add(fileWithSlash.toPath()) + } + else + { + contextClasspathUrls.add(uri.toURL()) + selectorPaths.add(path) + } + } + + private fun debugClasspathSelectors(discoveryRequest: LauncherDiscoveryRequest) + { + discoveryRequest.getSelectorsByType(ClasspathRootSelector::class.java).forEach { + LogTools.debug("Selector: $it") + } + } + + private fun debugContextClassLoader(customClassLoader: URLClassLoader) + { + // make sure context class loader is working + customClassLoader.urLs.forEach { + LogTools.debug(it.toString()) + } + } +}