diff --git a/delta-coverage-core/build.gradle.kts b/delta-coverage-core/build.gradle.kts index 1fd6beac..f68e9bd5 100644 --- a/delta-coverage-core/build.gradle.kts +++ b/delta-coverage-core/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(deps.httpClient) implementation(deps.openCsv) implementation(deps.jackson) + implementation(deps.jacksonKotlin) testImplementation(deps.jimFs) } diff --git a/delta-coverage-gradle/build.gradle.kts b/delta-coverage-gradle/build.gradle.kts index a75bafe0..9315de31 100644 --- a/delta-coverage-gradle/build.gradle.kts +++ b/delta-coverage-gradle/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } gradlePlugin { - website.set("https://github.com/SurpSG/delta-coverage") + website.set("https://gw-kit.github.io/delta-coverage-plugin") vcsUrl.set("https://github.com/SurpSG/delta-coverage.git") plugins { @@ -22,7 +22,7 @@ dependencies { implementation(project(":delta-coverage-core")) implementation(deps.coverJetPlugin) - testImplementation(gradleApi()) // required to add this dependency explicitly after applying shadowJar plugin + testImplementation(gradleApi()) testImplementation(deps.jimFs) testRuntimeOnly(deps.kotlinJvm) @@ -30,6 +30,7 @@ dependencies { functionalTestImplementation(testFixtures(project)) functionalTestImplementation(deps.jgit) functionalTestImplementation(deps.gradleProbe) + functionalTestImplementation(deps.jacksonKotlin) testFixturesApi(project(":delta-coverage-core")) testFixturesImplementation(deps.kotestAssertions) diff --git a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts index f176243e..6033ebc3 100644 --- a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts +++ b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts @@ -1,6 +1,7 @@ import io.github.surpsg.deltacoverage.gradle.CoverageEngine import io.github.surpsg.deltacoverage.gradle.DeltaCoverageConfiguration import io.github.surpsg.deltacoverage.gradle.CoverageEntity.* +import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { java @@ -15,6 +16,11 @@ repositories { tasks.withType { useJUnitPlatform() + + testLogging { + events(TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.PASSED) + showStandardStreams = true + } } dependencies { diff --git a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java index 9ec93028..ea46845f 100644 --- a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java +++ b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java @@ -3,7 +3,10 @@ public class Class1 { public int covered(boolean arg) { - if(arg) { + for (int i = 0; i < 100; i++) { + System.out.println(i); + } + if (arg) { return 1; } return 0; @@ -22,4 +25,4 @@ public int partialCovered(boolean arg) { public int notCovered(boolean arg) { return arg ? 1 : 0; } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index bcb83209..f5281c1a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=3.6.0 +version=3.7.0 group=io.github.gw-kit org.gradle.parallel=true kotlin.code.style=official diff --git a/gradle/deps.versions.toml b/gradle/deps.versions.toml index 9205a892..9da19e37 100644 --- a/gradle/deps.versions.toml +++ b/gradle/deps.versions.toml @@ -10,13 +10,15 @@ logbackVer = "1.5.22" kotlinVer = "2.3.0" junitVer = "6.0.2" +junitPlatformVer = "1.11.4" mockkVer = "1.14.7" kotestVer = "6.1.1" jimfsVer = "1.3.1" picocliVer = "4.7.6" +jfrConverterVer = "4.3" coverJetVer = "0.1.4" -deltaCoverageVer = "3.5.1" +deltaCoverageVer = "3.7.0" gradleProbeVer = "0.0.2" [libraries] @@ -30,6 +32,7 @@ detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version # Project deps gradleProbe = { module = "io.github.gw-kit:gradle-probe", version.ref = "gradleProbeVer" } +jfrConverter = { module = "tools.profiler:jfr-converter", version.ref = "jfrConverterVer" } coverJetPlugin = { module = "io.github.gw-kit.cover-jet:io.github.gw-kit.cover-jet.gradle.plugin", version.ref = "coverJetVer" } intellijCoverage = { module = "org.jetbrains.intellij.deps:intellij-coverage-reporter", version.ref = "intellijCoverageVer" } intellijCoverageAgent = { module = "org.jetbrains.intellij.deps:intellij-coverage-agent", version.ref = "intellijCoverageVer" } @@ -46,6 +49,7 @@ logback = { module = "ch.qos.logback:logback-classic", version.ref = "logbackVer # Testing junitApi = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junitVer" } +junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformVer" } mockk = { module = "io.mockk:mockk", version.ref = "mockkVer" } kotestAssertions = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotestVer" } jimFs = { module = "com.google.jimfs:jimfs", version.ref = "jimfsVer" } diff --git a/settings.gradle.kts b/settings.gradle.kts index dd43a9c5..23d2cb49 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( "delta-coverage-cli", "delta-coverage-core", "delta-coverage-gradle", + "test-impact-gradle", ) dependencyResolutionManagement { diff --git a/test-impact-gradle/build.gradle.kts b/test-impact-gradle/build.gradle.kts new file mode 100644 index 00000000..628c3946 --- /dev/null +++ b/test-impact-gradle/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + `gradle-plugin-conventions` +} + +gradlePlugin { + website.set("https://gw-kit.github.io/delta-coverage-plugin") + vcsUrl.set("https://github.com/gw-kit/delta-coverage-plugin.git") + plugins { + create("testImpactPlugin") { + id = "io.github.gw-kit.test-impact" + displayName = "Test Impact Analysis" + description = "Sampling-based test-to-code mapping and impact analysis" + implementationClass = "io.github.gwkit.testimpact.gradle.TestImpactPlugin" + tags.set(listOf("test", "impact", "mapping", "jfr", "sampling", "performance")) + } + } +} + +dependencies { + // Jackson for JSON report output + implementation(deps.jackson) + implementation(deps.jacksonKotlin) + + // async-profiler JFR-to-flamegraph converter + implementation(deps.jfrConverter) + + // Unit tests + testImplementation(testFixtures(project(":delta-coverage-gradle"))) + + // Functional tests + functionalTestImplementation(deps.jacksonKotlin) + functionalTestImplementation(deps.gradleProbe) +} diff --git a/test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt b/test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt new file mode 100644 index 00000000..2e2ff538 --- /dev/null +++ b/test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt @@ -0,0 +1,76 @@ +package io.github.gwkit.testimpact.gradle + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.github.gwkit.gradleprobe.RestorableFile +import io.github.gwkit.gradleprobe.gradlerunner.runTask +import io.github.gwkit.gradleprobe.junit.GradlePluginTest +import io.github.gwkit.gradleprobe.junit.GradleRunnerInstance +import io.github.gwkit.gradleprobe.junit.ProjectFile +import io.github.gwkit.gradleprobe.junit.RootProjectDir +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.file.shouldBeAFile +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File + +@Suppress("UNCHECKED_CAST") +@GradlePluginTest("single-module-test-project") +class TestMappingFunctionalTest { + + @RootProjectDir + lateinit var rootProjectDir: File + + @ProjectFile("build.gradle.kts") + lateinit var buildFile: RestorableFile + + @GradleRunnerInstance + lateinit var gradleRunner: GradleRunner + + @BeforeEach + fun beforeEach() { + buildFile.restoreOriginContent() + } + + @Test + fun `flamegraph should be created when enabled`() { + // GIVEN + buildFile.file.appendText( + """ + testImpact { + enabled = true + includePackages.add("com.java.test") + reports { + html.set(true) + flamegraph.set(true) + } + } + """.trimIndent() + ) + + // WHEN + gradleRunner.runTask("test", "analyzeTestMapping") + .apply { + println(output) + } + + // THEN + val reportDir = rootProjectDir.resolve("build/reports/test-impact") + + // TODO +// reportDir.resolve("test-mapping.html").shouldBeAFile() + + val flamegraphFile = reportDir.resolve("flamegraph.html") + flamegraphFile.shouldBeAFile() + assertSoftly(flamegraphFile.readText()) { + shouldContain("canvas") + shouldContain("async-profiler") + } + } +} diff --git a/test-impact-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts new file mode 100644 index 00000000..6afdba4b --- /dev/null +++ b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts @@ -0,0 +1,26 @@ +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + java + kotlin("jvm") version "2.3.0" + id("io.github.gw-kit.test-impact") +} + +repositories { + mavenCentral() +} + +tasks.withType { + useJUnitPlatform() + + testLogging { + events(TestLogEvent.SKIPPED, TestLogEvent.FAILED, TestLogEvent.PASSED) + showStandardStreams = true + } +} + +dependencies { + testImplementation(platform("org.junit:junit-bom:6.0.2")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} diff --git a/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java new file mode 100644 index 00000000..dcd84e82 --- /dev/null +++ b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java @@ -0,0 +1,28 @@ +package com.java.test; + +public class Class1 { + + public int covered(boolean arg) { + for (int i = 0; i < 1000; i++) { + System.out.println(i); + } + if (arg) { + return 1; + } + return 0; + } + + public int partialCovered(boolean arg) { + int result; + if (arg) { + result = 1; + } else { + result = 0; + } + return result; + } + + public int notCovered(boolean arg) { + return arg ? 1 : 0; + } +} diff --git a/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/UnchagedClass.java b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/UnchagedClass.java new file mode 100644 index 00000000..1627a99b --- /dev/null +++ b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/UnchagedClass.java @@ -0,0 +1,6 @@ +package com.java.test; +public class UnchagedClass { + public void method() { + System.out.println(1); + } +} diff --git a/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java new file mode 100644 index 00000000..48372068 --- /dev/null +++ b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java @@ -0,0 +1,28 @@ +package com.java.test; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class Class1Test { + + private Class1 class1 = new Class1(); + + @Test + public void coveredShouldReturn1() { + int covered = class1.covered(true); + assertEquals(1, covered); + } + + @Test + public void coveredShouldReturn0() { + int covered = class1.covered(false); + assertEquals(0, covered); + } + + @Test + public void partialCoveredShouldReturn1() { + int covered = class1.partialCovered(true); + assertEquals(1, covered); + } +} diff --git a/test-impact-gradle/src/functionalTest/resources/single-module-test-project/test.diff.file b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/test.diff.file new file mode 100644 index 00000000..b3d1948e --- /dev/null +++ b/test-impact-gradle/src/functionalTest/resources/single-module-test-project/test.diff.file @@ -0,0 +1,33 @@ +Index: src/main/java/com/java/test/Class1.java +IDEA additional info: +Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP +<+>UTF-8 +=================================================================== +--- src/main/java/com/java/test/Class1.java (date 1563021950000) ++++ src/main/java/com/java/test/Class1.java (date 1563021990000) +@@ -3,15 +3,23 @@ + public class Class1 { + + public int covered(boolean arg) { ++ if(arg) { ++ return 1; ++ } + return 0; + } + + public int partialCovered(boolean arg) { +- int result = 0; ++ int result; ++ if (arg) { ++ result = 1; ++ } else { ++ result = 0; ++ } + return result; + } + + public int notCovered(boolean arg) { +- return 0; ++ return arg ? 1 : 0; + } + } diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt new file mode 100644 index 00000000..64b96530 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt @@ -0,0 +1,99 @@ +package io.github.gwkit.testimpact.gradle.config + +import io.github.gwkit.testimpact.gradle.utils.booleanProperty +import io.github.gwkit.testimpact.gradle.utils.new +import org.gradle.api.Action +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested +import javax.inject.Inject + +/** + * Configuration for test-to-code mapping via JFR stack sampling. + * + * Example usage: + * ```kotlin + * testImpact { + * enabled = true + * includePackages.set(listOf("com.example")) + * excludePackages.addAll("org.springframework", "com.fasterxml") + * reports { + * json.set(true) + * html.set(true) + * flamegraph.set(true) + * } + * } + * ``` + */ +open class TestImpactConfiguration @Inject constructor( + objectFactory: ObjectFactory, +) { + /** + * Enables or disables test-to-code mapping. + * Defaults to false. + */ + @Input + val enabled: Property = objectFactory.booleanProperty(false) + + /** + * Package prefixes to include in the mapping. + * If empty, all packages are included (except those in excludePackages). + * Example: ["com.example", "org.mycompany"] + */ + @Input + val includePackages: ListProperty = objectFactory.listProperty(String::class.java) + .convention(emptyList()) + + /** + * Additional package prefixes to exclude from the mapping. + * These are added to the default excludes (JUnit, Gradle, JDK internals). + * Example: ["org.springframework", "com.fasterxml"] + */ + @Input + val excludePackages: ListProperty = objectFactory.listProperty(String::class.java) + .convention(emptyList()) + + /** + * Output directory for all reports. + * Defaults to "build/reports/test-impact". + */ + @Input + val reportOutputDir: Property = objectFactory.property(String::class.java) + .convention("build/reports/test-impact") + + /** Report type toggles. */ + @Nested + val reports: ReportConfiguration = objectFactory.new() + + /** Configures report type toggles. */ + fun reports(action: Action) { + action.execute(reports) + } +} + +/** + * Configuration for test-impact report types. + * + * Example usage: + * ```kotlin + * testImpact { + * reports { + * html.set(false) // default: true + * flamegraph.set(true) // default: false (d3-flame-graph) + * } + * } + * ``` + */ +open class ReportConfiguration @Inject constructor( + objectFactory: ObjectFactory, +) { + /** Enable interactive HTML report output. Defaults to false. */ + @Input + val html: Property = objectFactory.booleanProperty(false) + + /** Enable flamegraph HTML report output (d3-flame-graph). Defaults to false. */ + @Input + val flamegraph: Property = objectFactory.booleanProperty(false) +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt new file mode 100644 index 00000000..cffc1dae --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt @@ -0,0 +1,130 @@ +package io.github.gwkit.testimpact.gradle + +import io.github.gwkit.testimpact.gradle.config.TestImpactConfiguration +import io.github.gwkit.testimpact.gradle.task.GenerateJfcConfigTask +import io.github.gwkit.testimpact.gradle.task.TestMappingAnalysisTask +import io.github.gwkit.testimpact.gradle.test.listener.TestEventsCollector +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.testing.Test +import org.gradle.process.CommandLineArgumentProvider +import java.io.File + +open class TestImpactPlugin : Plugin { + + override fun apply(project: Project) { + val config: TestImpactConfiguration = project.extensions.create( + EXTENSION_NAME, + TestImpactConfiguration::class.java, + project.objects, + ) + + project.configureAllTestTasks(config) + } + + private fun Project.configureAllTestTasks( + config: TestImpactConfiguration, + ) { + val generateJfcTask: TaskProvider = project.registerGenerateJfcTask(config) + project.allprojects { proj -> + val analyzeTask: TaskProvider = proj.registerAnalyzeTask(config) + proj.tasks.withType(Test::class.java).configureEach { testTask -> + val jfrOutputs: TestImpactOutputs = testTask.applyStackTracesCollecting(config, generateJfcTask) + with(analyzeTask.get()) { + jfrFiles.from(jfrOutputs.jfrData) + testEventsFiles.from(jfrOutputs.testEventsFile) + mustRunAfter(testTask) + } + } + } + } + + private fun Project.registerAnalyzeTask( + config: TestImpactConfiguration, + ): TaskProvider = tasks.register( + ANALYZE_TASK_NAME, + TestMappingAnalysisTask::class.java + ) { task -> + task.onlyIf { + config.enabled.get() + } + task.outputDirectory.set( + project.layout.projectDirectory.dir(config.reportOutputDir) + ) + task.includePackages.set(config.includePackages) + task.excludePackages.set(config.excludePackages) + task.htmlEnabled.set(config.reports.html) + task.flamegraphEnabled.set(config.reports.flamegraph) + } + + private fun Project.registerGenerateJfcTask( + config: TestImpactConfiguration, + ): TaskProvider { + val taskName = "generateJfcConfig" + return project.tasks.register(taskName, GenerateJfcConfigTask::class.java) { task -> + task.onlyIf { config.enabled.get() } + task.jfcFile.set(layout.buildDirectory.file("sampling/stacktrace-sampling.jfc")) + } + } + + private fun Test.applyStackTracesCollecting( + config: TestImpactConfiguration, + generateJfcTask: TaskProvider, + ): TestImpactOutputs { + val additionalTaskOutputs = TestImpactOutputs( + testEventsFile = temporaryDir.resolve("${name}-$TEST_EVENTS_FILENAME"), + jfrData = temporaryDir.resolve("${name}-$JFR_FILENAME"), + ) + inputs.files(generateJfcTask) + outputs.files( + additionalTaskOutputs.jfrData, + additionalTaskOutputs.testEventsFile, + ) + + if (config.enabled.get()) { + addTestListener(TestEventsCollector(additionalTaskOutputs.testEventsFile)) + + jvmArgumentProviders.add( + JfrCommandLineProvider( + config.enabled, + generateJfcTask.flatMap { it.jfcFile }.map { it.asFile }, + additionalTaskOutputs.jfrData + ) + ) + } + + return additionalTaskOutputs + } + + private class JfrCommandLineProvider( + private val enabled: Provider, + private val jfcFile: Provider, + private val jfrFile: File + ) : CommandLineArgumentProvider { + override fun asArguments(): Iterable = if (enabled.get()) { + val jvmArg = sequenceOf( + "-XX:StartFlightRecording=filename=${jfrFile.absolutePath}", + "settings=${jfcFile.get().absolutePath}", + "dumponexit=true", + ).joinToString(",") + listOf(jvmArg) + } else { + emptyList() + } + } + + private data class TestImpactOutputs( + val testEventsFile: File, + val jfrData: File, + ) + + companion object { + const val EXTENSION_NAME = "testImpact" + const val ANALYZE_TASK_NAME = "analyzeTestMapping" + + private const val JFR_FILENAME = "recording.jfr" + private const val TEST_EVENTS_FILENAME = "test-events.txt" + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphData.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphData.kt new file mode 100644 index 00000000..c8395fd5 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphData.kt @@ -0,0 +1,10 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis + +/** + * Collapsed stack data for flamegraph rendering. + * + * @property collapsedStacks map of "frame1;frame2;frame3" to sample count + */ +internal data class FlamegraphData( + val collapsedStacks: Map, +) diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphDataCollector.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphDataCollector.kt new file mode 100644 index 00000000..25ee4084 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphDataCollector.kt @@ -0,0 +1,89 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis + +import jdk.jfr.consumer.RecordedFrame +import jdk.jfr.consumer.RecordingFile +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Reads JFR files and collects ALL execution sample stacks (unfiltered) + * for full execution profile flamegraph rendering. + */ +internal object FlamegraphDataCollector { + + private val log: Logger = LoggerFactory.getLogger(FlamegraphDataCollector::class.java) + + fun collect( + jfrFiles: Collection, + testClasses: Set = emptySet(), + ): FlamegraphData { + val collapsedStacks: Map = jfrFiles + .asSequence() + .filter { it.exists() } + .flatMap { jfrFile -> collectFromFile(jfrFile, testClasses) } + .groupingBy { it } + .eachCount() + return FlamegraphData(collapsedStacks) + } + + private fun collectFromFile( + jfrFile: File, + testClasses: Set, + ): Sequence = try { + log.info("Collecting flamegraph data from: {}", jfrFile.absolutePath) + val frameFormatter: (RecordedFrame) -> String = frameFormatter(testClasses) + RecordingFile.readAllEvents(jfrFile.toPath()) + .asSequence() + .filter { it.eventType.name == "jdk.ExecutionSample" } + .mapNotNull { it.stackTrace } + .map { stackTrace -> trimToTestRoot(stackTrace.frames.reversed(), testClasses) } + .map { trimmedFrames -> trimmedFrames.joinToString(";", transform = frameFormatter) } + .filter { it.isNotEmpty() } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + log.warn("Failed to read JFR file for flamegraph {}: {}", jfrFile.absolutePath, e.message) + emptySequence() + } + + private fun frameFormatter( + testClasses: Set + ): (RecordedFrame) -> String = { frame -> + val className = frame.method.type.name + val name = "$className.${frame.method.name}" + val isTestFrame = testClasses.any { className.contains(it) } + val suffix: String = frame.frameSuffix(isTestFrame) + "$name$suffix" + } + + private fun RecordedFrame.frameSuffix(isTestFrame: Boolean): String = if (isTestFrame) { + "_[k]" + } else if (this.isJavaFrame) { + when (this.type) { + "JIT compiled" -> "_[j]" + "Inlined" -> "_[i]" + "Interpreted" -> "_[0]" + else -> "_[j]" + } + } else { + "_[c]" + } + + /** + * Trims the stack to start from the test frame, filtering out non-test stacks. + * + * Returns `null` if no test frame is found (stack is discarded). + */ + private fun trimToTestRoot( + frames: List, + testClasses: Set, + ): List { + if (testClasses.isEmpty()) return frames + val testIndex = frames.indexOfFirst { frame -> + testClasses.any { frame.method.type.name.contains(it) } + } + return if (testIndex >= 0) { + frames.subList(testIndex, frames.size) + } else emptyList() + } + +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt new file mode 100644 index 00000000..30aec9a6 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt @@ -0,0 +1,354 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis + +import jdk.jfr.consumer.RecordedFrame +import jdk.jfr.consumer.RecordedMethod +import jdk.jfr.consumer.RecordedStackTrace +import jdk.jfr.consumer.RecordingFile +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.lang.reflect.Modifier +import java.time.Instant + +/** + * Analyzes JFR recordings to build test-to-code mappings. + */ +internal class JfrTestMappingAnalyzer( + private val config: AnalyzerConfig = AnalyzerConfig() +) { + private val log: Logger = LoggerFactory.getLogger(JfrTestMappingAnalyzer::class.java) + + fun analyze(jfrFiles: Collection, testClasses: Set): TestMappingReport { + if (testClasses.isEmpty()) { + return buildReport(MappingResult(emptyMap(), 0), emptySet()) + } + + val result = analyzeJfrFiles(jfrFiles, testClasses) + return buildReport(result, testClasses) + } + + private fun analyzeJfrFiles(jfrFiles: Collection, testClasses: Set): MappingResult { + val methodMappings = mutableMapOf>() + var totalSamples = 0 + + jfrFiles + .filter { it.exists() } + .forEach { jfrFile -> + log.info("Analyzing JFR file: {}", jfrFile.absolutePath) + try { + RecordingFile.readAllEvents(jfrFile.toPath()) + .asSequence() + .filter { it.eventType.name == "jdk.ExecutionSample" } + .forEach eventLoop@{ event -> + val stackTrace = event.stackTrace ?: return@eventLoop + totalSamples++ + processStackTrace(stackTrace, testClasses, methodMappings) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + log.warn("Failed to read JFR file {}: {}", jfrFile.absolutePath, e.message) + } + } + + return MappingResult(methodMappings, totalSamples) + } + + private fun processStackTrace( + stackTrace: RecordedStackTrace, + testClasses: Set, + methodMappings: MutableMap> + ) { + val frames = stackTrace.frames.reversed() + + for (testClass in testClasses) { + val testFrameIndex = frames.indexOfFirst { it.method.type.name.contains(testClass) } + if (testFrameIndex == -1) continue + + val testFrame: RecordedFrame = frames[testFrameIndex] + + val testId = "${testFrame.method.type.name}#${testFrame.method.name}" + + // Process frames after the test frame (called by the test) + frames.drop(testFrameIndex + 1) + .take(config.maxCallDepth) + .filter { frame -> !Modifier.isPrivate(frame.method.modifiers) } + .filter { frame -> + val className = frame.method.type.name + !isExcludedPackage(className) && matchesIncludePattern(className) + } + .forEachIndexed { index, frame -> + val method: RecordedMethod = frame.method + val methodKey = MethodKey( + className = method.type.name, + methodName = method.name, + descriptor = method.descriptor, + modifiers = method.modifiers, + lineNumber = frame.lineNumber + ) + val depth = index + 1 + + val testHits = methodMappings.getOrPut(methodKey) { mutableMapOf() } + val existingHit = testHits[testId] + if (existingHit != null) { + testHits[testId] = existingHit.copy( + samples = existingHit.samples + 1, + minDepth = minOf(existingHit.minDepth, depth) + ) + } else { + testHits[testId] = TestHit(testId = testId, samples = 1, minDepth = depth) + } + } + } + } + + private fun isExcludedPackage(className: String): Boolean { + return EXCLUDED_PACKAGES.any { className.startsWith(it) } || + config.excludePackages.any { className.startsWith(it) } + } + + private fun matchesIncludePattern(className: String): Boolean { + val patterns = config.includePackages + return patterns.isEmpty() || patterns.any { className.startsWith(it) } + } + + private fun buildReport(result: MappingResult, testClasses: Set): TestMappingReport { + val methodMappings = result.methodMappings + + // Group by class + val mappings = methodMappings.entries + .groupBy { it.key.className } + .mapValues { (_, entries) -> + entries.associate { (methodKey, testHits) -> + val methodWithSignature = "${methodKey.methodName}${descriptorToSignature(methodKey.descriptor)}" + methodWithSignature to MethodMapping( + signature = descriptorToSignature(methodKey.descriptor), + visibility = modifiersToVisibility(methodKey.modifiers), + lineNumber = methodKey.lineNumber, + totalHits = testHits.values.sumOf { it.samples }, + tests = testHits.values + .sortedByDescending { it.samples } + .map { hit -> + TestReference( + id = hit.testId, + depth = hit.minDepth, + samples = hit.samples + ) + } + ) + } + } + + // Build hot methods list + val hotMethods = methodMappings.entries + .map { (methodKey, testHits) -> + val methodWithSignature = "${methodKey.methodName}${descriptorToSignature(methodKey.descriptor)}" + HotMethod( + method = "${methodKey.className}#$methodWithSignature", + totalHits = testHits.values.sumOf { it.samples }, + testCount = testHits.size + ) + } + .sortedByDescending { it.totalHits } + .take(config.topHotMethodsCount) + + return TestMappingReport( + version = 1, + generatedAt = Instant.now().toString(), + summary = ReportSummary( + totalTests = testClasses.size, + totalMethods = methodMappings.size, + totalSamples = result.totalSamples, + maxCallDepth = config.maxCallDepth + ), + mappings = mappings, + hotMethods = hotMethods + ) + } + + private data class MethodKey( + val className: String, + val methodName: String, + val descriptor: String, + val modifiers: Int, + val lineNumber: Int + ) + + private data class TestHit( + val testId: String, + val samples: Int, + val minDepth: Int + ) + + private data class MappingResult( + val methodMappings: Map>, + val totalSamples: Int + ) + + private companion object { + private val EXCLUDED_PACKAGES = listOf( + "org.junit", + "org.gradle", + "jdk.internal", + "sun.", + "java.lang.reflect", + "org.opentest4j", + "worker.org.gradle" + ) + + /** + * Converts JVM modifiers to visibility string. + */ + fun modifiersToVisibility(modifiers: Int): String = when { + Modifier.isPublic(modifiers) -> "public" + Modifier.isPrivate(modifiers) -> "private" + Modifier.isProtected(modifiers) -> "protected" + else -> "package-private" + } + + /** + * Converts JVM method descriptor to human-readable signature. + * Example: "(II)I" -> "(int, int)" + * Example: "(Ljava/lang/String;I)V" -> "(String, int)" + */ + fun descriptorToSignature(descriptor: String): String { + val params = parseDescriptorParams(descriptor) + return "(${params.joinToString(", ")})" + } + + private fun parseDescriptorParams(descriptor: String): List { + if (!descriptor.startsWith("(")) return emptyList() + + val params = mutableListOf() + var i = 1 // skip opening '(' + + while (i < descriptor.length && descriptor[i] != ')') { + val (type, newIndex) = parseType(descriptor, i) + params.add(type) + i = newIndex + } + return params + } + + @Suppress("CyclomaticComplexMethod") + private fun parseType(descriptor: String, startIndex: Int): Pair { + var i = startIndex + var arrayDepth = 0 + + // Count array dimensions + while (i < descriptor.length && descriptor[i] == '[') { + arrayDepth++ + i++ + } + + val (baseType, nextIndex) = when (descriptor[i]) { + 'B' -> "byte" to i + 1 + 'C' -> "char" to i + 1 + 'D' -> "double" to i + 1 + 'F' -> "float" to i + 1 + 'I' -> "int" to i + 1 + 'J' -> "long" to i + 1 + 'S' -> "short" to i + 1 + 'Z' -> "boolean" to i + 1 + 'V' -> "void" to i + 1 + 'L' -> { + // Object type: Ljava/lang/String; + val semicolonIndex = descriptor.indexOf(';', i) + val fullClassName = descriptor.substring(i + 1, semicolonIndex).replace('/', '.') + val simpleName = fullClassName.substringAfterLast('.') + simpleName to semicolonIndex + 1 + } + else -> "?" to i + 1 + } + + val typeName = if (arrayDepth > 0) { + baseType + "[]".repeat(arrayDepth) + } else { + baseType + } + + return typeName to nextIndex + } + } +} + +/** + * Configuration for the JFR analyzer. + */ +data class AnalyzerConfig( + val maxCallDepth: Int = 20, + val topHotMethodsCount: Int = 20, + val includePackages: List = emptyList(), + val excludePackages: List = emptyList() +) + +/** + * Complete test mapping report. + */ +internal data class TestMappingReport( + val version: Int, + val generatedAt: String, + val summary: ReportSummary, + val mappings: Map>, + val hotMethods: List +) { + fun toMap(): Map = mapOf( + "version" to version, + "generatedAt" to generatedAt, + "summary" to mapOf( + "totalTests" to summary.totalTests, + "totalMethods" to summary.totalMethods, + "totalSamples" to summary.totalSamples, + "maxCallDepth" to summary.maxCallDepth + ), + "mappings" to mappings.mapValues { (_, methods) -> + methods.mapValues { (_, mapping) -> + mapOf( + "signature" to mapping.signature, + "visibility" to mapping.visibility, + "lineNumber" to mapping.lineNumber, + "totalHits" to mapping.totalHits, + "tests" to mapping.tests.map { test -> + mapOf( + "id" to test.id, + "depth" to test.depth, + "samples" to test.samples + ) + } + ) + } + }, + "hotMethods" to hotMethods.map { hot -> + mapOf( + "method" to hot.method, + "totalHits" to hot.totalHits, + "testCount" to hot.testCount + ) + } + ) +} + +internal data class ReportSummary( + val totalTests: Int, + val totalMethods: Int, + val totalSamples: Int, + val maxCallDepth: Int +) + +internal data class MethodMapping( + val signature: String, + val visibility: String, + val lineNumber: Int, + val totalHits: Int, + val tests: List +) + +data class TestReference( + val id: String, + val depth: Int, + val samples: Int +) + +data class HotMethod( + val method: String, + val totalHits: Int, + val testCount: Int +) diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/AsyncProfilerFlamegraphReporter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/AsyncProfilerFlamegraphReporter.kt new file mode 100644 index 00000000..e398327d --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/AsyncProfilerFlamegraphReporter.kt @@ -0,0 +1,34 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.FlamegraphData +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.FlamegraphDataCollector +import one.convert.Arguments +import one.convert.FlameGraph +import java.io.File +import java.io.PrintStream +import java.io.StringReader + +/** + * Generates a self-contained flamegraph HTML report using async-profiler's canvas renderer. + * + * Accepts pre-collected [FlamegraphData] (collapsed stacks) and feeds them into + * async-profiler's [FlameGraph] for rendering. + */ +internal object AsyncProfilerFlamegraphReporter : Reporter { + + private const val FILE_NAME = "flamegraph.html" + private const val TITLE = "Execution Profile" + + override fun write(context: ReportContext): File { + val flamegraphData = FlamegraphDataCollector.collect(context.jfrFiles, testClasses = context.testClasses) + val collapsed = flamegraphData.collapsedStacks.entries + .joinToString("\n") { (stack, count) -> "$stack $count" } + + val fg = FlameGraph(Arguments("--title", TITLE)) + fg.parseCollapsed(StringReader(collapsed)) + + return context.config.outputDir.resolve(FILE_NAME).apply { + PrintStream(this, "UTF-8").use { fg.dump(it) } + } + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/HtmlTestMappingReporter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/HtmlTestMappingReporter.kt new file mode 100644 index 00000000..91b949b9 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/HtmlTestMappingReporter.kt @@ -0,0 +1,29 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +import groovy.json.JsonOutput +import java.io.File + +/** + * Generates a self-contained interactive HTML report from test mapping data. + */ +internal object HtmlTestMappingReporter : Reporter { + + private const val FILE_NAME = "test-mapping.html" + private const val TEMPLATE_PATH = "/io/github/gwkit/testimpact/gradle/report/test-mapping-report.html" + private const val DATA_PLACEHOLDER = "/*__DATA__*/null" + + override fun write(context: ReportContext): File { + val jsonData = JsonOutput.toJson(context.report.toMap()) + val html = loadResource().replace(DATA_PLACEHOLDER, jsonData) + + return context.config.outputDir.resolve(FILE_NAME).apply { + writeText(html) + } + } + + private fun loadResource(): String = + HtmlTestMappingReporter::class.java.getResourceAsStream(TEMPLATE_PATH) + ?.bufferedReader() + ?.readText() + ?: error("Template resource not found: $TEMPLATE_PATH") +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportConfig.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportConfig.kt new file mode 100644 index 00000000..8342bfff --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportConfig.kt @@ -0,0 +1,12 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +/** + * Plain configuration for which report types to generate. + */ +import java.io.File + +internal data class ReportConfig( + val outputDir: File, + val html: Boolean, + val flamegraph: Boolean, +) diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportWriter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportWriter.kt new file mode 100644 index 00000000..38bcd44d --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportWriter.kt @@ -0,0 +1,30 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.TestMappingReport +import java.io.File + +/** + * Orchestrates generation of all enabled report types. + */ +internal class ReportWriter( + private val config: ReportConfig, +) { + + private val reporters: Iterable = buildReporters(config) + + fun write(report: TestMappingReport, jfrFiles: Collection, testClasses: Set): List { + config.outputDir.mkdirs() + val context = ReportContext(config, report, jfrFiles, testClasses) + return reporters.map { reporter -> reporter.write(context) } + } + + companion object { + + private fun buildReporters(config: ReportConfig): Collection = mapOf( + config.html to HtmlTestMappingReporter, + config.flamegraph to AsyncProfilerFlamegraphReporter, + ) + .filterKeys { it } + .values + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/Reporter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/Reporter.kt new file mode 100644 index 00000000..581f8177 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/Reporter.kt @@ -0,0 +1,21 @@ +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.TestMappingReport +import java.io.File + +/** + * Input data available to all reporters. + */ +internal data class ReportContext( + val config: ReportConfig, + val report: TestMappingReport, + val jfrFiles: Collection, + val testClasses: Set, +) + +/** + * Generates a single report file. + */ +internal fun interface Reporter { + fun write(context: ReportContext): File +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/GenerateJfcConfigTask.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/GenerateJfcConfigTask.kt new file mode 100644 index 00000000..02765900 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/GenerateJfcConfigTask.kt @@ -0,0 +1,57 @@ +package io.github.gwkit.testimpact.gradle.task + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import javax.inject.Inject + +/** + * Task that generates a JFC (Java Flight Configuration) file + * optimized for stack trace sampling. + */ +abstract class GenerateJfcConfigTask @Inject constructor() : DefaultTask() { + + init { + group = "verification" + description = "Generates JFC config file for JFR stack trace sampling" + } + + @get:OutputFile + abstract val jfcFile: RegularFileProperty + + @TaskAction + fun generate() { + val file = jfcFile.get().asFile + file.parentFile?.mkdirs() + file.writeText(JFC_CONFIG) + logger.debug("Created JFC config file: {}", file.absolutePath) + } + + companion object { + /** + * JFC configuration optimized for stack trace sampling. + * - ExecutionSample at 1ms interval for frequent sampling + * - All other events disabled to minimize overhead + */ + private val JFC_CONFIG = """ + + + + true + 1 ms + true + + + false + + + false + + + false + + + """.trimIndent() + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt new file mode 100644 index 00000000..ae99daec --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt @@ -0,0 +1,93 @@ +package io.github.gwkit.testimpact.gradle.task + +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.AnalyzerConfig +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.JfrTestMappingAnalyzer +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.TestMappingReport +import io.github.gwkit.testimpact.gradle.sampling.testmapping.report.ReportConfig +import io.github.gwkit.testimpact.gradle.sampling.testmapping.report.ReportWriter +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Task that analyzes JFR recordings and generates test-to-code mapping reports. + */ +abstract class TestMappingAnalysisTask : DefaultTask() { + + init { + group = "verification" + description = "Analyzes JFR recordings to map tests to code" + } + + @get:InputFiles + @get:Optional + abstract val jfrFiles: ConfigurableFileCollection + + @get:InputFiles + @get:Optional + abstract val testEventsFiles: ConfigurableFileCollection + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @get:Input + @get:Optional + abstract val includePackages: ListProperty + + @get:Input + @get:Optional + abstract val excludePackages: ListProperty + + @get:Input + abstract val htmlEnabled: Property + + @get:Input + abstract val flamegraphEnabled: Property + + @TaskAction + fun analyze() { + val testClasses: Set = loadTestClasses() + if (testClasses.isEmpty()) { + logger.lifecycle("No test classes found in test-events files") + } else { + logger.lifecycle("Loaded {} test classes", testClasses.size) + } + + val config = AnalyzerConfig( + includePackages = includePackages.getOrElse(emptyList()), + excludePackages = excludePackages.getOrElse(emptyList()) + ) + val report: TestMappingReport = JfrTestMappingAnalyzer(config).analyze(jfrFiles.files, testClasses) + + writeReports(report, testClasses) + } + + private fun loadTestClasses(): Set = testEventsFiles.files + .filter { it.exists() } + .flatMap { it.readLines() } + .filter { it.isNotBlank() } + .toSet() + + private fun writeReports(report: TestMappingReport, testClasses: Set) { + val outputDir = outputDirectory.get().asFile + + val reportConfig = ReportConfig( + outputDir = outputDir, + html = htmlEnabled.get(), + flamegraph = flamegraphEnabled.get(), + ) + val writer = ReportWriter(reportConfig) + + val generatedFiles = writer.write(report, jfrFiles.files, testClasses) + generatedFiles.forEach { file -> + logger.lifecycle("Report: file://{}", file.absolutePath) + } + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt new file mode 100644 index 00000000..2c53376e --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt @@ -0,0 +1,34 @@ +package io.github.gwkit.testimpact.gradle.test.listener + +import org.gradle.api.tasks.testing.TestDescriptor +import org.gradle.api.tasks.testing.TestListener +import org.gradle.api.tasks.testing.TestResult +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Gradle TestListener that collects test class names during test execution. + * Writes collected test classes to a file when test suite finishes. + */ +internal class TestEventsCollector( + private val outputFile: File +) : TestListener { + + private val testClasses = ConcurrentHashMap.newKeySet() + + override fun beforeSuite(suite: TestDescriptor) = Unit + + override fun afterSuite(suite: TestDescriptor, result: TestResult) { + // Write collected test classes to file when root suite finishes + if (suite.parent == null && testClasses.isNotEmpty()) { + outputFile.parentFile?.mkdirs() + outputFile.writeText(testClasses.sorted().joinToString("\n")) + } + } + + override fun beforeTest(testDescriptor: TestDescriptor) = Unit + + override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) { + testDescriptor.className?.let { testClasses.add(it) } + } +} diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/utils/Objects.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/utils/Objects.kt new file mode 100644 index 00000000..4a91261f --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/utils/Objects.kt @@ -0,0 +1,10 @@ +package io.github.gwkit.testimpact.gradle.utils + +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +internal inline fun ObjectFactory.new(): T = newInstance(T::class.java) + +internal fun ObjectFactory.booleanProperty(default: Boolean): Property { + return property(Boolean::class.javaObjectType).convention(default) +} diff --git a/test-impact-gradle/src/test/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPluginTest.kt b/test-impact-gradle/src/test/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPluginTest.kt new file mode 100644 index 00000000..1effdf20 --- /dev/null +++ b/test-impact-gradle/src/test/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPluginTest.kt @@ -0,0 +1,39 @@ +package io.github.gwkit.testimpact.gradle + +import io.github.gwkit.testimpact.gradle.config.TestImpactConfiguration +import io.github.gwkit.testimpact.gradle.task.GenerateJfcConfigTask +import io.github.surpsg.deltacoverage.gradle.unittest.testJavaProject +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.collections.shouldContainAll +import io.kotest.matchers.string.shouldContain +import org.gradle.api.internal.tasks.TaskExecutionOutcome +import org.junit.jupiter.api.Test +import org.gradle.api.tasks.testing.Test as TestTask + +class TestImpactPluginTest { + + @Test + fun `apply plugin should configure test task with JFR argument provider when enabled`() { + // GIVEN // WHEN + val project = testJavaProject { + pluginManager.apply(TestImpactPlugin::class.java) + extensions.configure(TestImpactConfiguration::class.java) { config -> + config.enabled.set(true) + } + tasks.named("generateJfcConfig", GenerateJfcConfigTask::class.java) { + it.state.outcome = TaskExecutionOutcome.EXECUTED + } + } + + // THEN + assertSoftly(project.tasks.withType(TestTask::class.java).getByName("test")) { + jvmArgumentProviders.single().asArguments().single() shouldContain Regex( + "-XX:StartFlightRecording=filename=.*,settings=.*,dumponexit=true" + ) + outputs.files.map { it.name } shouldContainAll listOf( + "${name}-test-events.txt", + "${name}-recording.jfr", + ) + } + } +}