From 54f613b15d9d5ef48bc80f1d11a71fbbad7f998c Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Mon, 2 Feb 2026 18:42:08 +0200 Subject: [PATCH 01/15] draft --- build.gradle.kts | 7 + delta-coverage-core/build.gradle.kts | 1 + .../surpsg/deltacoverage/sampling/Sample.kt | 58 ++++++ .../deltacoverage/sampling/SamplingConfig.kt | 42 ++++ .../deltacoverage/sampling/StackSampler.kt | 131 ++++++++++++ .../sampling/output/RawSamplesWriter.kt | 86 ++++++++ delta-coverage-gradle/build.gradle.kts | 39 ++++ .../gradle/TestMappingFunctionalTest.kt | 91 ++++++++ .../build.gradle.kts | 6 + .../test/java/com/java/test/Class1Test.java | 1 + .../gradle/DeltaCoverageConfiguration.kt | 8 + .../gradle/DeltaCoveragePlugin.kt | 2 + .../sampling/TestMappingConfiguration.kt | 140 +++++++++++++ .../gradle/sampling/TestMappingIntegration.kt | 197 ++++++++++++++++++ .../sampling/listener/SamplingTestListener.kt | 167 +++++++++++++++ gradle.properties | 2 +- gradle/deps.versions.toml | 4 +- 17 files changed, 980 insertions(+), 2 deletions(-) create mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt create mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt create mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt create mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt create mode 100644 delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt create mode 100644 delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt diff --git a/build.gradle.kts b/build.gradle.kts index 39c61df6..a26582a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,4 +13,11 @@ deltaCoverageReport { excludeClasses.addAll( "**/deltacoverage/demo/*" ) + testMapping { + enabled = true + sampling { + intervalMs = 1 + maxDepth = 50 + } + } } 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-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt new file mode 100644 index 00000000..c8f93a80 --- /dev/null +++ b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt @@ -0,0 +1,58 @@ +package io.github.surpsg.deltacoverage.sampling + +/** + * Represents a single stack sample captured during test execution. + * + * @property timestamp Unix timestamp in milliseconds when the sample was captured + * @property testId Identifier of the test that was running when sample was captured + * @property threadName Name of the sampled thread + * @property frames Stack frames from the sample, filtered to application code + */ +data class Sample( + val timestamp: Long, + val testId: TestIdentifier, + val threadName: String, + val frames: List, +) + +/** + * Identifies a specific test method. + * + * @property className Fully qualified class name of the test + * @property methodName Name of the test method + * @property displayName Human-readable display name of the test + */ +data class TestIdentifier( + val className: String, + val methodName: String, + val displayName: String, +) { + /** + * Returns a compact string representation suitable for JSON output. + */ + fun toCompactString(): String = "$className#$methodName" +} + +/** + * Represents a single stack frame. + * + * @property className Fully qualified class name + * @property methodName Method name + * @property lineNumber Line number in source file, or -1 if unavailable + */ +data class StackFrame( + val className: String, + val methodName: String, + val lineNumber: Int, +) { + companion object { + /** + * Creates a StackFrame from a Java StackTraceElement. + */ + fun fromStackTraceElement(element: StackTraceElement): StackFrame = StackFrame( + className = element.className, + methodName = element.methodName, + lineNumber = element.lineNumber, + ) + } +} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt new file mode 100644 index 00000000..7344b00c --- /dev/null +++ b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt @@ -0,0 +1,42 @@ +package io.github.surpsg.deltacoverage.sampling + +/** + * Configuration for stack sampling during test execution. + * + * @property intervalMs Sampling interval in milliseconds + * @property maxDepth Maximum stack depth to capture + * @property excludePackagePrefixes Package prefixes to exclude from stack frames + */ +data class SamplingConfig( + val intervalMs: Long = DEFAULT_INTERVAL_MS, + val maxDepth: Int = DEFAULT_MAX_DEPTH, + val excludePackagePrefixes: Set = DEFAULT_EXCLUDES, +) { + init { + require(intervalMs > 0) { "Sampling interval must be positive, was: $intervalMs" } + require(maxDepth > 0) { "Max depth must be positive, was: $maxDepth" } + } + + companion object { + const val DEFAULT_INTERVAL_MS = 1L + const val DEFAULT_MAX_DEPTH = 50 + + val DEFAULT_EXCLUDES: Set = setOf( + "java.", + "javax.", + "jdk.", + "sun.", + "com.sun.", + "org.junit.", + "org.gradle.", + "worker.org.gradle.", + "org.testng.", + "kotlin.", + "kotlinx.", + "io.mockk.", + "io.kotest.", + "org.mockito.", + "io.github.surpsg.deltacoverage.sampling.", + ) + } +} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt new file mode 100644 index 00000000..fad4a58e --- /dev/null +++ b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt @@ -0,0 +1,131 @@ +package io.github.surpsg.deltacoverage.sampling + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Stack sampler that periodically captures stack traces during test execution. + * + * Thread-safe and designed to handle parallel test execution. + */ +class StackSampler( + private val config: SamplingConfig = SamplingConfig(), +) { + private val running = AtomicBoolean(false) + private val threadToTest = ConcurrentHashMap() + private val samples = ConcurrentLinkedQueue() + + private var executor: ScheduledExecutorService? = null + private var samplingTask: ScheduledFuture<*>? = null + + /** + * Starts the sampling process. + * Safe to call multiple times; subsequent calls are no-ops if already running. + */ + fun start() { + if (!running.compareAndSet(false, true)) { + return + } + + executor = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "delta-coverage-sampler").apply { + isDaemon = true + } + } + + samplingTask = executor?.scheduleAtFixedRate( + ::captureAllThreads, + config.intervalMs, + config.intervalMs, + TimeUnit.MILLISECONDS + ) + } + + /** + * Stops the sampling process and returns all collected samples. + * + * @return List of all samples collected during the sampling session + */ + fun stop(): List { + if (!running.compareAndSet(true, false)) { + return emptyList() + } + + samplingTask?.cancel(false) + executor?.shutdown() + try { + executor?.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + + return samples.toList() + } + + /** + * Associates the current thread with a test. + * Called when a test starts execution. + * + * @param testId The test identifier to associate with the current thread + */ + fun setCurrentTest(testId: TestIdentifier) { + threadToTest[Thread.currentThread().id] = testId + } + + /** + * Clears the test association for the current thread. + * Called when a test finishes execution. + */ + fun clearCurrentTest() { + threadToTest.remove(Thread.currentThread().id) + } + + /** + * Returns true if the sampler is currently running. + */ + fun isRunning(): Boolean = running.get() + + /** + * Returns the current sample count. + */ + fun sampleCount(): Int = samples.size + + private fun captureAllThreads() { + val timestamp = System.currentTimeMillis() + val allStackTraces = Thread.getAllStackTraces() + + for ((thread, stackTrace) in allStackTraces) { + val testId = threadToTest[thread.id] ?: continue + + val filteredFrames = stackTrace + .take(config.maxDepth) + .filter { element -> shouldIncludeFrame(element) } + .map { element -> StackFrame.fromStackTraceElement(element) } + + if (filteredFrames.isNotEmpty()) { + samples.add( + Sample( + timestamp = timestamp, + testId = testId, + threadName = thread.name, + frames = filteredFrames, + ) + ) + } + } + } + + private fun shouldIncludeFrame(element: StackTraceElement): Boolean { + val className = element.className + return config.excludePackagePrefixes.none { prefix -> className.startsWith(prefix) } + } + + companion object { + private const val SHUTDOWN_TIMEOUT_MS = 1000L + } +} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt new file mode 100644 index 00000000..cc8b96b2 --- /dev/null +++ b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt @@ -0,0 +1,86 @@ +package io.github.surpsg.deltacoverage.sampling.output + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import io.github.surpsg.deltacoverage.sampling.Sample +import io.github.surpsg.deltacoverage.sampling.SamplingConfig +import java.nio.file.Path +import java.time.Instant +import java.time.format.DateTimeFormatter +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +/** + * Writes raw stack samples to a JSON file. + */ +object RawSamplesWriter { + + private val objectMapper: ObjectMapper = ObjectMapper() + .registerKotlinModule() + .enable(SerializationFeature.INDENT_OUTPUT) + + /** + * Writes samples to the specified output file. + * + * @param outputPath Path to the output JSON file + * @param samples List of samples to write + * @param config Sampling configuration used during collection + */ + fun write( + outputPath: Path, + samples: List, + config: SamplingConfig, + ) { + outputPath.parent?.createDirectories() + + val output = SamplesOutput( + version = OUTPUT_VERSION, + generatedAt = DateTimeFormatter.ISO_INSTANT.format(Instant.now()), + samplingIntervalMs = config.intervalMs, + maxDepth = config.maxDepth, + totalSamples = samples.size, + samples = samples.map { it.toOutputSample() }, + ) + + val json = objectMapper.writeValueAsString(output) + outputPath.writeText(json) + } + + private fun Sample.toOutputSample(): OutputSample = OutputSample( + timestamp = timestamp, + testId = testId.toCompactString(), + threadName = threadName, + frames = frames.map { frame -> + OutputFrame( + `class` = frame.className, + method = frame.methodName, + line = frame.lineNumber, + ) + }, + ) + + private const val OUTPUT_VERSION = 1 +} + +internal data class SamplesOutput( + val version: Int, + val generatedAt: String, + val samplingIntervalMs: Long, + val maxDepth: Int, + val totalSamples: Int, + val samples: List, +) + +internal data class OutputSample( + val timestamp: Long, + val testId: String, + val threadName: String, + val frames: List, +) + +internal data class OutputFrame( + val `class`: String, + val method: String, + val line: Int, +) diff --git a/delta-coverage-gradle/build.gradle.kts b/delta-coverage-gradle/build.gradle.kts index a75bafe0..8f0cd269 100644 --- a/delta-coverage-gradle/build.gradle.kts +++ b/delta-coverage-gradle/build.gradle.kts @@ -3,6 +3,40 @@ plugins { `java-test-fixtures` } +// Create testListener source set for the JUnit Platform TestExecutionListener +val testListenerSourceSet = sourceSets.create("testListener") { + java.srcDir("src/testListener/kotlin") + resources.srcDir("src/testListener/resources") +} + +// Create testListener JAR task +val testListenerJar by tasks.registering(Jar::class) { + archiveClassifier.set("test-listener") + from(testListenerSourceSet.output) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Handle duplicates in processTestListenerResources +tasks.named("processTestListenerResources") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Include testListener classes in the main JAR so they are available at runtime +// BUT exclude the ServiceLoader registration - we don't want to auto-register +// the listener when the plugin JAR is on the classpath (e.g., in functional tests) +tasks.named("jar") { + from(testListenerSourceSet.output) { + exclude("META-INF/services/**") + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + dependsOn(tasks.named("testListenerClasses")) +} + +// Create configuration for testListener dependencies +val testListenerImplementation by configurations.getting { + extendsFrom(configurations.implementation.get()) +} + gradlePlugin { website.set("https://github.com/SurpSG/delta-coverage") vcsUrl.set("https://github.com/SurpSG/delta-coverage.git") @@ -22,6 +56,10 @@ dependencies { implementation(project(":delta-coverage-core")) implementation(deps.coverJetPlugin) + // testListener source set dependencies + testListenerImplementation(project(":delta-coverage-core")) + testListenerImplementation(deps.junitPlatformLauncher) + testImplementation(gradleApi()) // required to add this dependency explicitly after applying shadowJar plugin testImplementation(deps.jimFs) testRuntimeOnly(deps.kotlinJvm) @@ -30,6 +68,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/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt new file mode 100644 index 00000000..1c1002a9 --- /dev/null +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -0,0 +1,91 @@ +package io.github.surpsg.deltacoverage.gradle + +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.shouldBe +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 + +@GradlePluginTest(TestProjects.SINGLE_MODULE, kts = false) +class TestMappingFunctionalTest { + + @RootProjectDir + lateinit var rootProjectDir: File + + @ProjectFile("test.diff.file") + lateinit var diffFilePath: String + + @ProjectFile("build.gradle") + lateinit var buildFile: RestorableFile + + @GradleRunnerInstance + lateinit var gradleRunner: GradleRunner + + @BeforeEach + fun beforeEach() { + buildFile.restoreOriginContent() + } + + @Test + fun `test mapping DSL should be configurable and test task should be configured`() { + // GIVEN + val samplesFile = "build/reports/delta-coverage/test-samples.json" + buildFile.file.appendText( + """ + deltaCoverageReport { + diffSource.file.set('$diffFilePath') + testMapping { + enabled = true + sampling { + intervalMs = 1 + maxDepth = 50 + } + output { + samplesFile = '$samplesFile' + } + } + } + + // Verify the configuration is applied by checking test task's systemProperties + // Note: systemProperty() sets properties for the forked test JVM, not the Gradle JVM + tasks.named('test') { + doFirst { + def props = systemProperties + def enabledProp = props['delta.coverage.sampling.enabled'] + def intervalProp = props['delta.coverage.sampling.intervalMs'] + def maxDepthProp = props['delta.coverage.sampling.maxDepth'] + def outputProp = props['delta.coverage.sampling.outputFile'] + + println "TEST_MAPPING_CONFIG: enabled=${'$'}enabledProp, intervalMs=${'$'}intervalProp, maxDepth=${'$'}maxDepthProp" + println "TEST_MAPPING_OUTPUT: ${'$'}outputProp" + + assert enabledProp == 'true' : "Expected enabled=true but got ${'$'}enabledProp" + assert intervalProp == '1' : "Expected intervalMs=1 but got ${'$'}intervalProp" + assert maxDepthProp == '50' : "Expected maxDepth=50 but got ${'$'}maxDepthProp" + assert outputProp?.contains('test-samples.json') : "Expected output path to contain test-samples.json" + } + } + """.trimIndent() + ) + + // WHEN + val result = gradleRunner.runTask("test", "--info") + + // THEN - verify the configuration was applied by checking the output + assertSoftly { + result.output.shouldContain("TEST_MAPPING_CONFIG: enabled=true, intervalMs=1, maxDepth=50") + result.output.shouldContain("test-samples.json") + } + + // Note: In TestKit, the listener JARs may not be found, so samples file may not be created + // This test verifies DSL configuration is correctly passed to test task + } +} 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/test/java/com/java/test/Class1Test.java b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java index 48372068..82a03b89 100644 --- a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java +++ b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java @@ -10,6 +10,7 @@ public class Class1Test { @Test public void coveredShouldReturn1() { + System.out.println(12222222); int covered = class1.covered(true); assertEquals(1, covered); } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt index b79bad29..92512344 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt @@ -1,6 +1,7 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.dsl.view.view +import io.github.surpsg.deltacoverage.gradle.sampling.TestMappingConfiguration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty import io.github.surpsg.deltacoverage.gradle.utils.doubleProperty @@ -50,6 +51,9 @@ open class DeltaCoverageConfiguration @Inject constructor( @Nested val reportConfiguration: ReportsConfiguration = ReportsConfiguration(objectFactory) + @Nested + val testMapping: TestMappingConfiguration = objectFactory.new() + @Internal val reportViews: NamedDomainObjectContainer = objectFactory.domainObjectContainer(ReportView::class.java) { name -> @@ -68,6 +72,10 @@ open class DeltaCoverageConfiguration @Inject constructor( action.execute(diffSource) } + fun testMapping(action: Action) { + action.execute(testMapping) + } + /** * Configures a [ReportView] for the report. * If the view is not found, it will be created. diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt index 64845866..17f22004 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt @@ -2,6 +2,7 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.autoapply.CoverageEngineAutoApply import io.github.surpsg.deltacoverage.gradle.reportview.ViewLookup +import io.github.surpsg.deltacoverage.gradle.sampling.TestMappingIntegration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTask import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.task.NativeGitDiffTask @@ -27,6 +28,7 @@ open class DeltaCoveragePlugin : Plugin { objects, ) CoverageEngineAutoApply().applyEngine(project) + TestMappingIntegration.configure(project) val deltaTaskForViewConfigurer: (String) -> Unit = deltaTaskForViewConfigurer() diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt new file mode 100644 index 00000000..5cd58577 --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt @@ -0,0 +1,140 @@ +package io.github.surpsg.deltacoverage.gradle.sampling + +import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty +import io.github.surpsg.deltacoverage.gradle.utils.new +import io.github.surpsg.deltacoverage.gradle.utils.stringProperty +import io.github.surpsg.deltacoverage.sampling.SamplingConfig +import org.gradle.api.Action +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Nested +import java.nio.file.Paths +import javax.inject.Inject + +/** + * Configuration for test-to-code mapping via stack sampling. + * + * Example usage: + * ```kotlin + * deltaCoverageReport { + * testMapping { + * enabled = true + * sampling { + * intervalMs = 1 + * maxDepth = 50 + * } + * output { + * samplesFile = "build/reports/delta-coverage/test-samples.json" + * } + * } + * } + * ``` + */ +open class TestMappingConfiguration @Inject constructor( + objectFactory: ObjectFactory, +) { + /** + * Enables or disables test-to-code mapping. + * Defaults to false. + */ + @Input + val enabled: Property = objectFactory.booleanProperty(false) + + /** + * Sampling configuration. + */ + @Nested + val sampling: SamplingConfiguration = objectFactory.new() + + /** + * Output configuration. + */ + @Nested + val output: OutputConfiguration = objectFactory.new() + + /** + * Configures sampling settings. + */ + fun sampling(action: Action) { + action.execute(sampling) + } + + /** + * Configures output settings. + */ + fun output(action: Action) { + action.execute(output) + } + + override fun toString(): String = "TestMappingConfiguration(" + + "enabled=${enabled.get()}, " + + "sampling=$sampling, " + + "output=$output)" +} + +/** + * Configuration for the stack sampling process. + */ +open class SamplingConfiguration @Inject constructor( + objectFactory: ObjectFactory, +) { + /** + * Sampling interval in milliseconds. + * Defaults to 1ms. + */ + @Input + val intervalMs: Property = objectFactory + .property(Long::class.javaObjectType) + .convention(SamplingConfig.DEFAULT_INTERVAL_MS) + + /** + * Maximum stack depth to capture. + * Defaults to 50. + */ + @Input + val maxDepth: Property = objectFactory + .property(Int::class.javaObjectType) + .convention(SamplingConfig.DEFAULT_MAX_DEPTH) + + /** + * Package prefixes to exclude from stack frames. + * Defaults to common framework packages. + */ + @Input + val excludePackagePrefixes: SetProperty = objectFactory + .setProperty(String::class.java) + .convention(SamplingConfig.DEFAULT_EXCLUDES) + + /** + * Converts this configuration to a core SamplingConfig. + */ + internal fun toSamplingConfig(): SamplingConfig = SamplingConfig( + intervalMs = intervalMs.get(), + maxDepth = maxDepth.get(), + excludePackagePrefixes = excludePackagePrefixes.get(), + ) + + override fun toString(): String = "SamplingConfiguration(" + + "intervalMs=${intervalMs.get()}, " + + "maxDepth=${maxDepth.get()})" +} + +/** + * Configuration for test mapping output. + */ +open class OutputConfiguration @Inject constructor( + objectFactory: ObjectFactory, +) { + /** + * Path to the output samples JSON file. + * Defaults to build/reports/delta-coverage/test-samples.json + */ + @Input + val samplesFile: Property = objectFactory.stringProperty { + Paths.get("build", "reports", "delta-coverage", "test-samples.json").toString() + } + + override fun toString(): String = "OutputConfiguration(samplesFile='${samplesFile.get()}')" +} diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt new file mode 100644 index 00000000..7d850d18 --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt @@ -0,0 +1,197 @@ +package io.github.surpsg.deltacoverage.gradle.sampling + +import io.github.surpsg.deltacoverage.gradle.DeltaCoverageConfiguration +import io.github.surpsg.deltacoverage.gradle.DeltaCoveragePlugin +import io.github.surpsg.deltacoverage.gradle.utils.deltaCoverageConfig +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.slf4j.LoggerFactory + +/** + * Integrates test-to-code mapping with test tasks. + */ +internal object TestMappingIntegration { + + private val log = LoggerFactory.getLogger(TestMappingIntegration::class.java) + + /** + * System property keys for passing configuration to test JVM. + */ + object SystemProperties { + const val ENABLED = "delta.coverage.sampling.enabled" + const val INTERVAL_MS = "delta.coverage.sampling.intervalMs" + const val MAX_DEPTH = "delta.coverage.sampling.maxDepth" + const val EXCLUDE_PREFIXES = "delta.coverage.sampling.excludePrefixes" + const val OUTPUT_FILE = "delta.coverage.sampling.outputFile" + } + + /** + * Configures all test tasks with sampling if enabled. + */ + fun configure(project: Project) { + project.afterEvaluate { + val config = project.deltaCoverageConfig.testMapping + if (!config.enabled.get()) { + log.debug("Test mapping is disabled") + return@afterEvaluate + } + + log.info("Test mapping is enabled, configuring test tasks") + configureTestTasks(project, config) + } + } + + private fun configureTestTasks(project: Project, config: TestMappingConfiguration) { + // Configure test tasks in root project + configureProjectTestTasks(project, config) + + // Configure test tasks in all subprojects + project.subprojects { subproject -> + subproject.afterEvaluate { + configureProjectTestTasks(subproject, config) + } + } + } + + private fun configureProjectTestTasks(project: Project, config: TestMappingConfiguration) { + project.tasks.withType(Test::class.java).configureEach { testTask -> + configureTestTask(testTask, config, project) + } + } + + private fun configureTestTask( + testTask: Test, + config: TestMappingConfiguration, + project: Project, + ) { + log.info("Configuring test task '${testTask.name}' in project '${project.path}' for sampling") + + val samplingConfig = config.sampling + val outputConfig = config.output + + // Pass configuration via system properties + testTask.systemProperty(SystemProperties.ENABLED, "true") + testTask.systemProperty(SystemProperties.INTERVAL_MS, samplingConfig.intervalMs.get().toString()) + testTask.systemProperty(SystemProperties.MAX_DEPTH, samplingConfig.maxDepth.get().toString()) + testTask.systemProperty( + SystemProperties.EXCLUDE_PREFIXES, + samplingConfig.excludePackagePrefixes.get().joinToString(",") + ) + + // Resolve output file path relative to project root + val outputFile = project.rootProject.projectDir.resolve(outputConfig.samplesFile.get()) + testTask.systemProperty(SystemProperties.OUTPUT_FILE, outputFile.absolutePath) + + testTask.jvmArgs( + "-Djunit.platform.launcher.interceptors.enabled=true" + ) + + // Add test listener to test classpath with ServiceLoader registration + addTestListenerToClasspath(testTask, project) + } + + private fun addTestListenerToClasspath(testTask: Test, project: Project) { + // Find the JARs containing the plugin and core classes first + // If we can't find them, skip listener registration to avoid ServiceLoader errors + // We need exactly 2 JARs: one for the listener (gradle module) and one for the sampler (core module) + val pluginJars = findPluginJars() + if (pluginJars.size < REQUIRED_JARS_COUNT) { + log.warn( + "Could not find all delta-coverage plugin JARs for test mapping (found {} of {} required). " + + "Test sampling will be disabled for task '${testTask.name}'. " + + "This may happen in test environments like Gradle TestKit.", + pluginJars.size, REQUIRED_JARS_COUNT + ) + return + } + + val listenerConfig = project.configurations.findByName(LISTENER_CONFIGURATION_NAME) + ?: project.configurations.create(LISTENER_CONFIGURATION_NAME) { config -> + config.isCanBeConsumed = false + config.isCanBeResolved = true + } + + // Add required dependencies for the test listener + project.dependencies.add( + LISTENER_CONFIGURATION_NAME, + "org.junit.platform:junit-platform-launcher:${JUNIT_PLATFORM_VERSION}" + ) + + // Add Jackson dependencies for JSON serialization + project.dependencies.add( + LISTENER_CONFIGURATION_NAME, + "com.fasterxml.jackson.core:jackson-databind:${JACKSON_VERSION}" + ) + project.dependencies.add( + LISTENER_CONFIGURATION_NAME, + "com.fasterxml.jackson.module:jackson-module-kotlin:${JACKSON_VERSION}" + ) + + log.info("Adding {} plugin JARs to test classpath for sampling", pluginJars.size) + pluginJars.forEach { jar -> + testTask.classpath += project.files(jar) + } + + // Create a temp directory with ServiceLoader registration for the listener + val serviceLoaderDir = createServiceLoaderDir(project) + testTask.classpath += project.files(serviceLoaderDir) + + testTask.classpath += listenerConfig + } + + private fun createServiceLoaderDir(project: Project): java.io.File { + val baseDir = project.layout.buildDirectory.dir("delta-coverage-sampling").get().asFile + val servicesDir = baseDir.resolve("META-INF/services") + servicesDir.mkdirs() + + val serviceFile = servicesDir.resolve("org.junit.platform.launcher.TestExecutionListener") + serviceFile.writeText(SAMPLING_LISTENER_CLASS) + + return baseDir + } + + private fun findPluginJars(): List { + val jars = mutableListOf() + + // Find the JARs containing the plugin and core classes + // We use classes that don't have external dependencies to avoid NoClassDefFoundError + // - TestMappingIntegration is in the gradle plugin JAR (same JAR as SamplingTestListener) + // - StackSampler is in the core JAR + val classesToFind = listOf( + TestMappingIntegration::class.java, // gradle plugin JAR + io.github.surpsg.deltacoverage.sampling.StackSampler::class.java, // core JAR + ) + + for (cls in classesToFind) { + try { + val location = cls.protectionDomain?.codeSource?.location + if (location != null) { + val file = java.io.File(location.toURI()) + // Only accept actual JAR files, not directories or other paths + // In TestKit/special classloader environments, the path might be a directory + if (file.exists() && file.isFile && file.name.endsWith(".jar") && file !in jars) { + log.debug("Found JAR for {}: {}", cls.name, file.absolutePath) + jars.add(file) + } else { + log.debug( + "Skipping invalid path for {}: {} (exists={}, isFile={}, isJar={})", + cls.name, file.absolutePath, file.exists(), file.isFile, file.name.endsWith(".jar") + ) + } + } else { + log.warn("No code source location for class {}", cls.name) + } + } catch (e: Exception) { + log.warn("Could not find JAR for class {}: {}", cls.name, e.message) + } + } + + return jars + } + + private const val LISTENER_CONFIGURATION_NAME = "deltaCoverageSamplingListener" + private const val JUNIT_PLATFORM_VERSION = "1.11.4" + private const val JACKSON_VERSION = "2.21.0" + private const val SAMPLING_LISTENER_CLASS = "io.github.surpsg.deltacoverage.gradle.sampling.listener.SamplingTestListener" + private const val REQUIRED_JARS_COUNT = 2 +} diff --git a/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt b/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt new file mode 100644 index 00000000..2d151aac --- /dev/null +++ b/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt @@ -0,0 +1,167 @@ +package io.github.surpsg.deltacoverage.gradle.sampling.listener + +import io.github.surpsg.deltacoverage.sampling.Sample +import io.github.surpsg.deltacoverage.sampling.SamplingConfig +import io.github.surpsg.deltacoverage.sampling.StackSampler +import io.github.surpsg.deltacoverage.sampling.TestIdentifier +import io.github.surpsg.deltacoverage.sampling.output.RawSamplesWriter +import org.junit.platform.engine.TestExecutionResult +import org.junit.platform.launcher.TestExecutionListener +import org.junit.platform.launcher.TestIdentifier as JUnitTestIdentifier +import org.junit.platform.launcher.TestPlan +import org.slf4j.LoggerFactory +import java.nio.file.Paths + +/** + * JUnit Platform TestExecutionListener that performs stack sampling during test execution. + * + * This listener is registered via ServiceLoader and activated via system properties. + */ +class SamplingTestListener : TestExecutionListener { + + init { + // This will print when the class is instantiated by ServiceLoader + println("[SamplingTestListener] Instance created - listener loaded successfully") + } + + private var sampler: StackSampler? = null + private var config: SamplingConfig? = null + private var outputFile: String? = null + private var enabled: Boolean = false + + override fun testPlanExecutionStarted(testPlan: TestPlan) { + log.info("testPlanExecutionStarted called") + + println("aaaaaaaaaaaaaaaaaaaaaaaaa") + + val enabledProp = System.getProperty(PROP_ENABLED) + enabled = enabledProp?.toBoolean() ?: false + log.info("Sampling enabled: {} (property value: '{}')", enabled, enabledProp) + + if (!enabled) { + log.info("Sampling is disabled, skipping initialization") + return + } + + config = buildConfig() + outputFile = System.getProperty(PROP_OUTPUT_FILE) + log.info("Configuration: intervalMs={}, maxDepth={}, outputFile={}", + config?.intervalMs, config?.maxDepth, outputFile) + + sampler = StackSampler(config!!).also { it.start() } + log.info("Stack sampler started with interval={}ms, maxDepth={}", + config?.intervalMs, config?.maxDepth) + } + + override fun executionStarted(testIdentifier: JUnitTestIdentifier) { + if (!enabled || !testIdentifier.isTest) { + return + } + + val testId = testIdentifier.toTestIdentifier() + log.info("Test started: {}#{}", testId.className, testId.methodName) + sampler?.setCurrentTest(testId) + } + + override fun executionFinished(testIdentifier: JUnitTestIdentifier, testExecutionResult: TestExecutionResult) { + if (!enabled || !testIdentifier.isTest) { + return + } + + log.info("Test finished: {} (status: {})", testIdentifier.displayName, testExecutionResult.status) + sampler?.clearCurrentTest() + } + + override fun testPlanExecutionFinished(testPlan: TestPlan) { + log.info("testPlanExecutionFinished called, enabled={}", enabled) + if (!enabled) { + return + } + + val samples: List = sampler?.stop() ?: emptyList() + log.info("Stack sampler stopped, collected {} samples", samples.size) + + val output = outputFile + val samplingConfig = config + if (output != null && samplingConfig != null) { + log.info("Writing samples to: {}", output) + try { + RawSamplesWriter.write( + outputPath = Paths.get(output), + samples = samples, + config = samplingConfig, + ) + log.info("Successfully wrote {} samples to {}", samples.size, output) + } catch (e: Exception) { + log.error("Failed to write samples to {}: {}", output, e.message, e) + } + } else { + log.warn("Cannot write samples: outputFile={}, config={}", output, samplingConfig) + } + } + + private fun buildConfig(): SamplingConfig { + val intervalMs = System.getProperty(PROP_INTERVAL_MS)?.toLongOrNull() + ?: SamplingConfig.DEFAULT_INTERVAL_MS + val maxDepth = System.getProperty(PROP_MAX_DEPTH)?.toIntOrNull() + ?: SamplingConfig.DEFAULT_MAX_DEPTH + val excludePrefixes = System.getProperty(PROP_EXCLUDE_PREFIXES) + ?.split(",") + ?.filter { it.isNotBlank() } + ?.toSet() + ?: SamplingConfig.DEFAULT_EXCLUDES + + return SamplingConfig( + intervalMs = intervalMs, + maxDepth = maxDepth, + excludePackagePrefixes = excludePrefixes, + ) + } + + private fun JUnitTestIdentifier.toTestIdentifier(): TestIdentifier { + val source = source.orElse(null) + val className = extractClassName(source) + val methodName = extractMethodName(source) + + return TestIdentifier( + className = className, + methodName = methodName, + displayName = displayName, + ) + } + + private fun extractClassName(source: Any?): String { + if (source == null) return UNKNOWN_CLASS + + return try { + val methodSource = source as? org.junit.platform.engine.support.descriptor.MethodSource + methodSource?.className ?: UNKNOWN_CLASS + } catch (_: Exception) { + UNKNOWN_CLASS + } + } + + private fun extractMethodName(source: Any?): String { + if (source == null) return UNKNOWN_METHOD + + return try { + val methodSource = source as? org.junit.platform.engine.support.descriptor.MethodSource + methodSource?.methodName ?: UNKNOWN_METHOD + } catch (_: Exception) { + UNKNOWN_METHOD + } + } + + companion object { + private val log = LoggerFactory.getLogger(SamplingTestListener::class.java) + + private const val PROP_ENABLED = "delta.coverage.sampling.enabled" + private const val PROP_INTERVAL_MS = "delta.coverage.sampling.intervalMs" + private const val PROP_MAX_DEPTH = "delta.coverage.sampling.maxDepth" + private const val PROP_EXCLUDE_PREFIXES = "delta.coverage.sampling.excludePrefixes" + private const val PROP_OUTPUT_FILE = "delta.coverage.sampling.outputFile" + + private const val UNKNOWN_CLASS = "" + private const val UNKNOWN_METHOD = "" + } +} 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..9d9352c1 100644 --- a/gradle/deps.versions.toml +++ b/gradle/deps.versions.toml @@ -10,13 +10,14 @@ 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" coverJetVer = "0.1.4" -deltaCoverageVer = "3.5.1" +deltaCoverageVer = "3.7.0" gradleProbeVer = "0.0.2" [libraries] @@ -46,6 +47,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" } From 193036a35a76cc997d8cc7c503fa1505fbf64116 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Mon, 2 Feb 2026 22:22:32 +0200 Subject: [PATCH 02/15] now we can collect stacktraces using jfr and match tests with the stacktraces --- .../surpsg/deltacoverage/sampling/Sample.kt | 58 ----- .../deltacoverage/sampling/SamplingConfig.kt | 42 --- .../deltacoverage/sampling/StackSampler.kt | 131 ---------- .../sampling/output/RawSamplesWriter.kt | 86 ------- delta-coverage-gradle/build.gradle.kts | 40 +-- .../gradle/TestMappingFunctionalTest.kt | 59 ++--- .../test/java/com/java/test/Class1Test.java | 1 - .../gradle/sampling/TestEventsCollector.kt | 34 +++ .../sampling/TestMappingConfiguration.kt | 112 +------- .../gradle/sampling/TestMappingIntegration.kt | 239 ++++++------------ .../gradle/task/TestMappingAnalysisTask.kt | 76 ++++++ .../sampling/listener/SamplingTestListener.kt | 167 ------------ 12 files changed, 211 insertions(+), 834 deletions(-) delete mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt delete mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt delete mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt delete mode 100644 delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt delete mode 100644 delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt deleted file mode 100644 index c8f93a80..00000000 --- a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/Sample.kt +++ /dev/null @@ -1,58 +0,0 @@ -package io.github.surpsg.deltacoverage.sampling - -/** - * Represents a single stack sample captured during test execution. - * - * @property timestamp Unix timestamp in milliseconds when the sample was captured - * @property testId Identifier of the test that was running when sample was captured - * @property threadName Name of the sampled thread - * @property frames Stack frames from the sample, filtered to application code - */ -data class Sample( - val timestamp: Long, - val testId: TestIdentifier, - val threadName: String, - val frames: List, -) - -/** - * Identifies a specific test method. - * - * @property className Fully qualified class name of the test - * @property methodName Name of the test method - * @property displayName Human-readable display name of the test - */ -data class TestIdentifier( - val className: String, - val methodName: String, - val displayName: String, -) { - /** - * Returns a compact string representation suitable for JSON output. - */ - fun toCompactString(): String = "$className#$methodName" -} - -/** - * Represents a single stack frame. - * - * @property className Fully qualified class name - * @property methodName Method name - * @property lineNumber Line number in source file, or -1 if unavailable - */ -data class StackFrame( - val className: String, - val methodName: String, - val lineNumber: Int, -) { - companion object { - /** - * Creates a StackFrame from a Java StackTraceElement. - */ - fun fromStackTraceElement(element: StackTraceElement): StackFrame = StackFrame( - className = element.className, - methodName = element.methodName, - lineNumber = element.lineNumber, - ) - } -} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt deleted file mode 100644 index 7344b00c..00000000 --- a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/SamplingConfig.kt +++ /dev/null @@ -1,42 +0,0 @@ -package io.github.surpsg.deltacoverage.sampling - -/** - * Configuration for stack sampling during test execution. - * - * @property intervalMs Sampling interval in milliseconds - * @property maxDepth Maximum stack depth to capture - * @property excludePackagePrefixes Package prefixes to exclude from stack frames - */ -data class SamplingConfig( - val intervalMs: Long = DEFAULT_INTERVAL_MS, - val maxDepth: Int = DEFAULT_MAX_DEPTH, - val excludePackagePrefixes: Set = DEFAULT_EXCLUDES, -) { - init { - require(intervalMs > 0) { "Sampling interval must be positive, was: $intervalMs" } - require(maxDepth > 0) { "Max depth must be positive, was: $maxDepth" } - } - - companion object { - const val DEFAULT_INTERVAL_MS = 1L - const val DEFAULT_MAX_DEPTH = 50 - - val DEFAULT_EXCLUDES: Set = setOf( - "java.", - "javax.", - "jdk.", - "sun.", - "com.sun.", - "org.junit.", - "org.gradle.", - "worker.org.gradle.", - "org.testng.", - "kotlin.", - "kotlinx.", - "io.mockk.", - "io.kotest.", - "org.mockito.", - "io.github.surpsg.deltacoverage.sampling.", - ) - } -} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt deleted file mode 100644 index fad4a58e..00000000 --- a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/StackSampler.kt +++ /dev/null @@ -1,131 +0,0 @@ -package io.github.surpsg.deltacoverage.sampling - -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Stack sampler that periodically captures stack traces during test execution. - * - * Thread-safe and designed to handle parallel test execution. - */ -class StackSampler( - private val config: SamplingConfig = SamplingConfig(), -) { - private val running = AtomicBoolean(false) - private val threadToTest = ConcurrentHashMap() - private val samples = ConcurrentLinkedQueue() - - private var executor: ScheduledExecutorService? = null - private var samplingTask: ScheduledFuture<*>? = null - - /** - * Starts the sampling process. - * Safe to call multiple times; subsequent calls are no-ops if already running. - */ - fun start() { - if (!running.compareAndSet(false, true)) { - return - } - - executor = Executors.newSingleThreadScheduledExecutor { runnable -> - Thread(runnable, "delta-coverage-sampler").apply { - isDaemon = true - } - } - - samplingTask = executor?.scheduleAtFixedRate( - ::captureAllThreads, - config.intervalMs, - config.intervalMs, - TimeUnit.MILLISECONDS - ) - } - - /** - * Stops the sampling process and returns all collected samples. - * - * @return List of all samples collected during the sampling session - */ - fun stop(): List { - if (!running.compareAndSet(true, false)) { - return emptyList() - } - - samplingTask?.cancel(false) - executor?.shutdown() - try { - executor?.awaitTermination(SHUTDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS) - } catch (_: InterruptedException) { - Thread.currentThread().interrupt() - } - - return samples.toList() - } - - /** - * Associates the current thread with a test. - * Called when a test starts execution. - * - * @param testId The test identifier to associate with the current thread - */ - fun setCurrentTest(testId: TestIdentifier) { - threadToTest[Thread.currentThread().id] = testId - } - - /** - * Clears the test association for the current thread. - * Called when a test finishes execution. - */ - fun clearCurrentTest() { - threadToTest.remove(Thread.currentThread().id) - } - - /** - * Returns true if the sampler is currently running. - */ - fun isRunning(): Boolean = running.get() - - /** - * Returns the current sample count. - */ - fun sampleCount(): Int = samples.size - - private fun captureAllThreads() { - val timestamp = System.currentTimeMillis() - val allStackTraces = Thread.getAllStackTraces() - - for ((thread, stackTrace) in allStackTraces) { - val testId = threadToTest[thread.id] ?: continue - - val filteredFrames = stackTrace - .take(config.maxDepth) - .filter { element -> shouldIncludeFrame(element) } - .map { element -> StackFrame.fromStackTraceElement(element) } - - if (filteredFrames.isNotEmpty()) { - samples.add( - Sample( - timestamp = timestamp, - testId = testId, - threadName = thread.name, - frames = filteredFrames, - ) - ) - } - } - } - - private fun shouldIncludeFrame(element: StackTraceElement): Boolean { - val className = element.className - return config.excludePackagePrefixes.none { prefix -> className.startsWith(prefix) } - } - - companion object { - private const val SHUTDOWN_TIMEOUT_MS = 1000L - } -} diff --git a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt b/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt deleted file mode 100644 index cc8b96b2..00000000 --- a/delta-coverage-core/src/main/kotlin/io/github/surpsg/deltacoverage/sampling/output/RawSamplesWriter.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.github.surpsg.deltacoverage.sampling.output - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.SerializationFeature -import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import io.github.surpsg.deltacoverage.sampling.Sample -import io.github.surpsg.deltacoverage.sampling.SamplingConfig -import java.nio.file.Path -import java.time.Instant -import java.time.format.DateTimeFormatter -import kotlin.io.path.createDirectories -import kotlin.io.path.writeText - -/** - * Writes raw stack samples to a JSON file. - */ -object RawSamplesWriter { - - private val objectMapper: ObjectMapper = ObjectMapper() - .registerKotlinModule() - .enable(SerializationFeature.INDENT_OUTPUT) - - /** - * Writes samples to the specified output file. - * - * @param outputPath Path to the output JSON file - * @param samples List of samples to write - * @param config Sampling configuration used during collection - */ - fun write( - outputPath: Path, - samples: List, - config: SamplingConfig, - ) { - outputPath.parent?.createDirectories() - - val output = SamplesOutput( - version = OUTPUT_VERSION, - generatedAt = DateTimeFormatter.ISO_INSTANT.format(Instant.now()), - samplingIntervalMs = config.intervalMs, - maxDepth = config.maxDepth, - totalSamples = samples.size, - samples = samples.map { it.toOutputSample() }, - ) - - val json = objectMapper.writeValueAsString(output) - outputPath.writeText(json) - } - - private fun Sample.toOutputSample(): OutputSample = OutputSample( - timestamp = timestamp, - testId = testId.toCompactString(), - threadName = threadName, - frames = frames.map { frame -> - OutputFrame( - `class` = frame.className, - method = frame.methodName, - line = frame.lineNumber, - ) - }, - ) - - private const val OUTPUT_VERSION = 1 -} - -internal data class SamplesOutput( - val version: Int, - val generatedAt: String, - val samplingIntervalMs: Long, - val maxDepth: Int, - val totalSamples: Int, - val samples: List, -) - -internal data class OutputSample( - val timestamp: Long, - val testId: String, - val threadName: String, - val frames: List, -) - -internal data class OutputFrame( - val `class`: String, - val method: String, - val line: Int, -) diff --git a/delta-coverage-gradle/build.gradle.kts b/delta-coverage-gradle/build.gradle.kts index 8f0cd269..3c73bf8e 100644 --- a/delta-coverage-gradle/build.gradle.kts +++ b/delta-coverage-gradle/build.gradle.kts @@ -3,40 +3,6 @@ plugins { `java-test-fixtures` } -// Create testListener source set for the JUnit Platform TestExecutionListener -val testListenerSourceSet = sourceSets.create("testListener") { - java.srcDir("src/testListener/kotlin") - resources.srcDir("src/testListener/resources") -} - -// Create testListener JAR task -val testListenerJar by tasks.registering(Jar::class) { - archiveClassifier.set("test-listener") - from(testListenerSourceSet.output) - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -// Handle duplicates in processTestListenerResources -tasks.named("processTestListenerResources") { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -// Include testListener classes in the main JAR so they are available at runtime -// BUT exclude the ServiceLoader registration - we don't want to auto-register -// the listener when the plugin JAR is on the classpath (e.g., in functional tests) -tasks.named("jar") { - from(testListenerSourceSet.output) { - exclude("META-INF/services/**") - } - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - dependsOn(tasks.named("testListenerClasses")) -} - -// Create configuration for testListener dependencies -val testListenerImplementation by configurations.getting { - extendsFrom(configurations.implementation.get()) -} - gradlePlugin { website.set("https://github.com/SurpSG/delta-coverage") vcsUrl.set("https://github.com/SurpSG/delta-coverage.git") @@ -56,11 +22,7 @@ dependencies { implementation(project(":delta-coverage-core")) implementation(deps.coverJetPlugin) - // testListener source set dependencies - testListenerImplementation(project(":delta-coverage-core")) - testListenerImplementation(deps.junitPlatformLauncher) - - testImplementation(gradleApi()) // required to add this dependency explicitly after applying shadowJar plugin + testImplementation(gradleApi()) testImplementation(deps.jimFs) testRuntimeOnly(deps.kotlinJvm) diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt index 1c1002a9..e2b10d01 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -6,8 +6,7 @@ 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.shouldBe +import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.string.shouldContain import org.gradle.testkit.runner.GradleRunner import org.junit.jupiter.api.BeforeEach @@ -35,57 +34,39 @@ class TestMappingFunctionalTest { } @Test - fun `test mapping DSL should be configurable and test task should be configured`() { + fun `test mapping should create JFR recording and test events files`() { // GIVEN - val samplesFile = "build/reports/delta-coverage/test-samples.json" buildFile.file.appendText( """ deltaCoverageReport { diffSource.file.set('$diffFilePath') testMapping { enabled = true - sampling { - intervalMs = 1 - maxDepth = 50 - } - output { - samplesFile = '$samplesFile' - } - } - } - - // Verify the configuration is applied by checking test task's systemProperties - // Note: systemProperty() sets properties for the forked test JVM, not the Gradle JVM - tasks.named('test') { - doFirst { - def props = systemProperties - def enabledProp = props['delta.coverage.sampling.enabled'] - def intervalProp = props['delta.coverage.sampling.intervalMs'] - def maxDepthProp = props['delta.coverage.sampling.maxDepth'] - def outputProp = props['delta.coverage.sampling.outputFile'] - - println "TEST_MAPPING_CONFIG: enabled=${'$'}enabledProp, intervalMs=${'$'}intervalProp, maxDepth=${'$'}maxDepthProp" - println "TEST_MAPPING_OUTPUT: ${'$'}outputProp" - - assert enabledProp == 'true' : "Expected enabled=true but got ${'$'}enabledProp" - assert intervalProp == '1' : "Expected intervalMs=1 but got ${'$'}intervalProp" - assert maxDepthProp == '50' : "Expected maxDepth=50 but got ${'$'}maxDepthProp" - assert outputProp?.contains('test-samples.json') : "Expected output path to contain test-samples.json" } } """.trimIndent() ) // WHEN - val result = gradleRunner.runTask("test", "--info") + val result = gradleRunner.runTask("test", "analyzeTestMapping") + + // THEN + // Check JFR file exists + val jfrFiles = rootProjectDir.walkTopDown() + .filter { it.name == "recording.jfr" } + .toList() + jfrFiles.shouldNotBeEmpty() + + // Check test-events file exists and contains test class + val testEventsFiles = rootProjectDir.walkTopDown() + .filter { it.name == "test-events.txt" } + .toList() + testEventsFiles.shouldNotBeEmpty() + testEventsFiles.first().readText() shouldContain "Class1Test" - // THEN - verify the configuration was applied by checking the output - assertSoftly { - result.output.shouldContain("TEST_MAPPING_CONFIG: enabled=true, intervalMs=1, maxDepth=50") - result.output.shouldContain("test-samples.json") - } + // Check analysis task output + result.output shouldContain "Matched stacktraces" - // Note: In TestKit, the listener JARs may not be found, so samples file may not be created - // This test verifies DSL configuration is correctly passed to test task + println(result.output) } } diff --git a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java index 82a03b89..48372068 100644 --- a/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java +++ b/delta-coverage-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java @@ -10,7 +10,6 @@ public class Class1Test { @Test public void coveredShouldReturn1() { - System.out.println(12222222); int covered = class1.covered(true); assertEquals(1, covered); } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt new file mode 100644 index 00000000..b06e2bff --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt @@ -0,0 +1,34 @@ +package io.github.surpsg.deltacoverage.gradle.sampling + +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/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt index 5cd58577..a93a7ec4 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt @@ -1,33 +1,19 @@ package io.github.surpsg.deltacoverage.gradle.sampling import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty -import io.github.surpsg.deltacoverage.gradle.utils.new -import io.github.surpsg.deltacoverage.gradle.utils.stringProperty -import io.github.surpsg.deltacoverage.sampling.SamplingConfig -import org.gradle.api.Action import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property -import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Nested -import java.nio.file.Paths import javax.inject.Inject /** - * Configuration for test-to-code mapping via stack sampling. + * Configuration for test-to-code mapping via JFR stack sampling. * * Example usage: * ```kotlin * deltaCoverageReport { * testMapping { * enabled = true - * sampling { - * intervalMs = 1 - * maxDepth = 50 - * } - * output { - * samplesFile = "build/reports/delta-coverage/test-samples.json" - * } * } * } * ``` @@ -41,100 +27,4 @@ open class TestMappingConfiguration @Inject constructor( */ @Input val enabled: Property = objectFactory.booleanProperty(false) - - /** - * Sampling configuration. - */ - @Nested - val sampling: SamplingConfiguration = objectFactory.new() - - /** - * Output configuration. - */ - @Nested - val output: OutputConfiguration = objectFactory.new() - - /** - * Configures sampling settings. - */ - fun sampling(action: Action) { - action.execute(sampling) - } - - /** - * Configures output settings. - */ - fun output(action: Action) { - action.execute(output) - } - - override fun toString(): String = "TestMappingConfiguration(" + - "enabled=${enabled.get()}, " + - "sampling=$sampling, " + - "output=$output)" -} - -/** - * Configuration for the stack sampling process. - */ -open class SamplingConfiguration @Inject constructor( - objectFactory: ObjectFactory, -) { - /** - * Sampling interval in milliseconds. - * Defaults to 1ms. - */ - @Input - val intervalMs: Property = objectFactory - .property(Long::class.javaObjectType) - .convention(SamplingConfig.DEFAULT_INTERVAL_MS) - - /** - * Maximum stack depth to capture. - * Defaults to 50. - */ - @Input - val maxDepth: Property = objectFactory - .property(Int::class.javaObjectType) - .convention(SamplingConfig.DEFAULT_MAX_DEPTH) - - /** - * Package prefixes to exclude from stack frames. - * Defaults to common framework packages. - */ - @Input - val excludePackagePrefixes: SetProperty = objectFactory - .setProperty(String::class.java) - .convention(SamplingConfig.DEFAULT_EXCLUDES) - - /** - * Converts this configuration to a core SamplingConfig. - */ - internal fun toSamplingConfig(): SamplingConfig = SamplingConfig( - intervalMs = intervalMs.get(), - maxDepth = maxDepth.get(), - excludePackagePrefixes = excludePackagePrefixes.get(), - ) - - override fun toString(): String = "SamplingConfiguration(" + - "intervalMs=${intervalMs.get()}, " + - "maxDepth=${maxDepth.get()})" -} - -/** - * Configuration for test mapping output. - */ -open class OutputConfiguration @Inject constructor( - objectFactory: ObjectFactory, -) { - /** - * Path to the output samples JSON file. - * Defaults to build/reports/delta-coverage/test-samples.json - */ - @Input - val samplesFile: Property = objectFactory.stringProperty { - Paths.get("build", "reports", "delta-coverage", "test-samples.json").toString() - } - - override fun toString(): String = "OutputConfiguration(samplesFile='${samplesFile.get()}')" } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt index 7d850d18..9b86d83c 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt @@ -1,197 +1,116 @@ package io.github.surpsg.deltacoverage.gradle.sampling -import io.github.surpsg.deltacoverage.gradle.DeltaCoverageConfiguration -import io.github.surpsg.deltacoverage.gradle.DeltaCoveragePlugin +import io.github.surpsg.deltacoverage.gradle.task.TestMappingAnalysisTask import io.github.surpsg.deltacoverage.gradle.utils.deltaCoverageConfig import org.gradle.api.Project import org.gradle.api.tasks.testing.Test import org.slf4j.LoggerFactory +import java.io.File /** - * Integrates test-to-code mapping with test tasks. + * Integrates test-to-code mapping with test tasks using JFR for stack trace collection. */ internal object TestMappingIntegration { private val log = LoggerFactory.getLogger(TestMappingIntegration::class.java) + private const val JFR_FILENAME = "recording.jfr" + private const val JFC_FILENAME = "stacktrace-sampling.jfc" + private const val TEST_EVENTS_FILENAME = "test-events.txt" + /** - * System property keys for passing configuration to test JVM. + * JFC configuration optimized for stack trace sampling. + * - ExecutionSample at 1ms interval for frequent sampling + * - All other events disabled to minimize overhead */ - object SystemProperties { - const val ENABLED = "delta.coverage.sampling.enabled" - const val INTERVAL_MS = "delta.coverage.sampling.intervalMs" - const val MAX_DEPTH = "delta.coverage.sampling.maxDepth" - const val EXCLUDE_PREFIXES = "delta.coverage.sampling.excludePrefixes" - const val OUTPUT_FILE = "delta.coverage.sampling.outputFile" - } + private val JFC_CONFIG = """ + + + + true + 1 ms + true + + + false + + + false + + + false + + + """.trimIndent() /** - * Configures all test tasks with sampling if enabled. + * Configures test tasks with JFR recording and test event collection if enabled. + * For POC: only configures root project test tasks. */ fun configure(project: Project) { - project.afterEvaluate { - val config = project.deltaCoverageConfig.testMapping - if (!config.enabled.get()) { - log.debug("Test mapping is disabled") - return@afterEvaluate - } - - log.info("Test mapping is enabled, configuring test tasks") - configureTestTasks(project, config) + val config = project.deltaCoverageConfig.testMapping + + // Register analysis task early + val analyzeTask = project.tasks.register( + "analyzeTestMapping", + TestMappingAnalysisTask::class.java + ) { task -> + task.group = "verification" + task.description = "Analyzes JFR recordings to map tests to code" } - } - private fun configureTestTasks(project: Project, config: TestMappingConfiguration) { - // Configure test tasks in root project - configureProjectTestTasks(project, config) - - // Configure test tasks in all subprojects - project.subprojects { subproject -> - subproject.afterEvaluate { - configureProjectTestTasks(subproject, config) + // Configure test tasks in root project only (POC simplification) + // Using whenTaskAdded to catch all Test tasks, including those created by plugins + project.tasks.withType(Test::class.java).all { testTask -> + // Defer configuration to afterEvaluate to ensure DSL is processed + project.afterEvaluate { + if (config.enabled.get()) { + log.info("Configuring test task '${testTask.name}' for JFR recording") + configureTestTask(testTask) + analyzeTask.configure { task -> + task.jfrFiles.from(testTask.temporaryDir.resolve(JFR_FILENAME)) + task.testEventsFiles.from(testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME)) + task.dependsOn(testTask) + } + } } } - } - private fun configureProjectTestTasks(project: Project, config: TestMappingConfiguration) { - project.tasks.withType(Test::class.java).configureEach { testTask -> - configureTestTask(testTask, config, project) + // Make deltaCoverage tasks finalized by analysis task + project.afterEvaluate { + if (config.enabled.get()) { + project.tasks.matching { it.name.startsWith("deltaCoverage") }.all { deltaCoverageTask -> + deltaCoverageTask.finalizedBy(analyzeTask) + } + } } } - private fun configureTestTask( - testTask: Test, - config: TestMappingConfiguration, - project: Project, - ) { - log.info("Configuring test task '${testTask.name}' in project '${project.path}' for sampling") - - val samplingConfig = config.sampling - val outputConfig = config.output - - // Pass configuration via system properties - testTask.systemProperty(SystemProperties.ENABLED, "true") - testTask.systemProperty(SystemProperties.INTERVAL_MS, samplingConfig.intervalMs.get().toString()) - testTask.systemProperty(SystemProperties.MAX_DEPTH, samplingConfig.maxDepth.get().toString()) - testTask.systemProperty( - SystemProperties.EXCLUDE_PREFIXES, - samplingConfig.excludePackagePrefixes.get().joinToString(",") - ) + private fun configureTestTask(testTask: Test) { + log.info("Configuring test task '${testTask.name}' for JFR recording") - // Resolve output file path relative to project root - val outputFile = project.rootProject.projectDir.resolve(outputConfig.samplesFile.get()) - testTask.systemProperty(SystemProperties.OUTPUT_FILE, outputFile.absolutePath) + // 1. Create JFC config file in temp dir + val jfcFile = testTask.temporaryDir.resolve(JFC_FILENAME) + createJfcConfigFile(jfcFile) + // 2. Add JFR JVM args with custom JFC settings + val jfrFile = testTask.temporaryDir.resolve(JFR_FILENAME) testTask.jvmArgs( - "-Djunit.platform.launcher.interceptors.enabled=true" - ) - - // Add test listener to test classpath with ServiceLoader registration - addTestListenerToClasspath(testTask, project) - } - - private fun addTestListenerToClasspath(testTask: Test, project: Project) { - // Find the JARs containing the plugin and core classes first - // If we can't find them, skip listener registration to avoid ServiceLoader errors - // We need exactly 2 JARs: one for the listener (gradle module) and one for the sampler (core module) - val pluginJars = findPluginJars() - if (pluginJars.size < REQUIRED_JARS_COUNT) { - log.warn( - "Could not find all delta-coverage plugin JARs for test mapping (found {} of {} required). " + - "Test sampling will be disabled for task '${testTask.name}'. " + - "This may happen in test environments like Gradle TestKit.", - pluginJars.size, REQUIRED_JARS_COUNT - ) - return - } - - val listenerConfig = project.configurations.findByName(LISTENER_CONFIGURATION_NAME) - ?: project.configurations.create(LISTENER_CONFIGURATION_NAME) { config -> - config.isCanBeConsumed = false - config.isCanBeResolved = true - } - - // Add required dependencies for the test listener - project.dependencies.add( - LISTENER_CONFIGURATION_NAME, - "org.junit.platform:junit-platform-launcher:${JUNIT_PLATFORM_VERSION}" + "-XX:StartFlightRecording=filename=${jfrFile.absolutePath},settings=${jfcFile.absolutePath},dumponexit=true" ) - // Add Jackson dependencies for JSON serialization - project.dependencies.add( - LISTENER_CONFIGURATION_NAME, - "com.fasterxml.jackson.core:jackson-databind:${JACKSON_VERSION}" - ) - project.dependencies.add( - LISTENER_CONFIGURATION_NAME, - "com.fasterxml.jackson.module:jackson-module-kotlin:${JACKSON_VERSION}" - ) + // 3. Add TestListener for collecting test class names + val testEventsFile = testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME) + testTask.addTestListener(TestEventsCollector(testEventsFile)) - log.info("Adding {} plugin JARs to test classpath for sampling", pluginJars.size) - pluginJars.forEach { jar -> - testTask.classpath += project.files(jar) - } - - // Create a temp directory with ServiceLoader registration for the listener - val serviceLoaderDir = createServiceLoaderDir(project) - testTask.classpath += project.files(serviceLoaderDir) - - testTask.classpath += listenerConfig + // 4. Register outputs for up-to-date checks + testTask.outputs.file(jfrFile) + testTask.outputs.file(testEventsFile) } - private fun createServiceLoaderDir(project: Project): java.io.File { - val baseDir = project.layout.buildDirectory.dir("delta-coverage-sampling").get().asFile - val servicesDir = baseDir.resolve("META-INF/services") - servicesDir.mkdirs() - - val serviceFile = servicesDir.resolve("org.junit.platform.launcher.TestExecutionListener") - serviceFile.writeText(SAMPLING_LISTENER_CLASS) - - return baseDir + private fun createJfcConfigFile(jfcFile: File) { + jfcFile.parentFile?.mkdirs() + jfcFile.writeText(JFC_CONFIG) + log.debug("Created JFC config file: ${jfcFile.absolutePath}") } - - private fun findPluginJars(): List { - val jars = mutableListOf() - - // Find the JARs containing the plugin and core classes - // We use classes that don't have external dependencies to avoid NoClassDefFoundError - // - TestMappingIntegration is in the gradle plugin JAR (same JAR as SamplingTestListener) - // - StackSampler is in the core JAR - val classesToFind = listOf( - TestMappingIntegration::class.java, // gradle plugin JAR - io.github.surpsg.deltacoverage.sampling.StackSampler::class.java, // core JAR - ) - - for (cls in classesToFind) { - try { - val location = cls.protectionDomain?.codeSource?.location - if (location != null) { - val file = java.io.File(location.toURI()) - // Only accept actual JAR files, not directories or other paths - // In TestKit/special classloader environments, the path might be a directory - if (file.exists() && file.isFile && file.name.endsWith(".jar") && file !in jars) { - log.debug("Found JAR for {}: {}", cls.name, file.absolutePath) - jars.add(file) - } else { - log.debug( - "Skipping invalid path for {}: {} (exists={}, isFile={}, isJar={})", - cls.name, file.absolutePath, file.exists(), file.isFile, file.name.endsWith(".jar") - ) - } - } else { - log.warn("No code source location for class {}", cls.name) - } - } catch (e: Exception) { - log.warn("Could not find JAR for class {}: {}", cls.name, e.message) - } - } - - return jars - } - - private const val LISTENER_CONFIGURATION_NAME = "deltaCoverageSamplingListener" - private const val JUNIT_PLATFORM_VERSION = "1.11.4" - private const val JACKSON_VERSION = "2.21.0" - private const val SAMPLING_LISTENER_CLASS = "io.github.surpsg.deltacoverage.gradle.sampling.listener.SamplingTestListener" - private const val REQUIRED_JARS_COUNT = 2 } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt new file mode 100644 index 00000000..83245a92 --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt @@ -0,0 +1,76 @@ +package io.github.surpsg.deltacoverage.gradle.task + +import jdk.jfr.consumer.RecordedStackTrace +import jdk.jfr.consumer.RecordingFile +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction + +/** + * Task that analyzes JFR recordings and matches stack traces with test classes. + */ +abstract class TestMappingAnalysisTask : DefaultTask() { + + @get:InputFiles + @get:Optional + abstract val jfrFiles: ConfigurableFileCollection + + @get:InputFiles + @get:Optional + abstract val testEventsFiles: ConfigurableFileCollection + + @TaskAction + fun analyze() { + val testClasses = loadTestClasses() + if (testClasses.isEmpty()) { + logger.lifecycle("No test classes found in test-events files") + return + } + logger.lifecycle("Loaded ${testClasses.size} test classes:") + testClasses.forEach { + logger.lifecycle(it) + } + + val matchedCount = analyzeJfrFiles(testClasses) + logger.lifecycle("Matched stacktraces containing test classes: $matchedCount") + } + + private fun loadTestClasses(): Set { + return testEventsFiles.files + .filter { it.exists() } + .flatMap { it.readLines() } + .filter { it.isNotBlank() } + .toSet() + } + + private fun analyzeJfrFiles(testClasses: Set): Int { + var count = 0 + jfrFiles.files + .filter { it.exists() } + .forEach { jfrFile -> + logger.info("Analyzing JFR file: ${jfrFile.absolutePath}") + try { + RecordingFile.readAllEvents(jfrFile.toPath()) + .filter { it.eventType.name == "jdk.ExecutionSample" } + .forEach { event -> + val stackTrace = event.stackTrace + if (stackTrace != null && containsTestClass(stackTrace, testClasses)) { + count++ + } + } + } catch (e: Exception) { + logger.warn("Failed to read JFR file ${jfrFile.absolutePath}: ${e.message}") + } + } + return count + } + + private fun containsTestClass(stackTrace: RecordedStackTrace, testClasses: Set): Boolean { + return stackTrace.frames.any { frame -> + val className = frame.method.type.name + testClasses.any { testClass -> className.contains(testClass) } + } + } +} diff --git a/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt b/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt deleted file mode 100644 index 2d151aac..00000000 --- a/delta-coverage-gradle/src/testListener/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/listener/SamplingTestListener.kt +++ /dev/null @@ -1,167 +0,0 @@ -package io.github.surpsg.deltacoverage.gradle.sampling.listener - -import io.github.surpsg.deltacoverage.sampling.Sample -import io.github.surpsg.deltacoverage.sampling.SamplingConfig -import io.github.surpsg.deltacoverage.sampling.StackSampler -import io.github.surpsg.deltacoverage.sampling.TestIdentifier -import io.github.surpsg.deltacoverage.sampling.output.RawSamplesWriter -import org.junit.platform.engine.TestExecutionResult -import org.junit.platform.launcher.TestExecutionListener -import org.junit.platform.launcher.TestIdentifier as JUnitTestIdentifier -import org.junit.platform.launcher.TestPlan -import org.slf4j.LoggerFactory -import java.nio.file.Paths - -/** - * JUnit Platform TestExecutionListener that performs stack sampling during test execution. - * - * This listener is registered via ServiceLoader and activated via system properties. - */ -class SamplingTestListener : TestExecutionListener { - - init { - // This will print when the class is instantiated by ServiceLoader - println("[SamplingTestListener] Instance created - listener loaded successfully") - } - - private var sampler: StackSampler? = null - private var config: SamplingConfig? = null - private var outputFile: String? = null - private var enabled: Boolean = false - - override fun testPlanExecutionStarted(testPlan: TestPlan) { - log.info("testPlanExecutionStarted called") - - println("aaaaaaaaaaaaaaaaaaaaaaaaa") - - val enabledProp = System.getProperty(PROP_ENABLED) - enabled = enabledProp?.toBoolean() ?: false - log.info("Sampling enabled: {} (property value: '{}')", enabled, enabledProp) - - if (!enabled) { - log.info("Sampling is disabled, skipping initialization") - return - } - - config = buildConfig() - outputFile = System.getProperty(PROP_OUTPUT_FILE) - log.info("Configuration: intervalMs={}, maxDepth={}, outputFile={}", - config?.intervalMs, config?.maxDepth, outputFile) - - sampler = StackSampler(config!!).also { it.start() } - log.info("Stack sampler started with interval={}ms, maxDepth={}", - config?.intervalMs, config?.maxDepth) - } - - override fun executionStarted(testIdentifier: JUnitTestIdentifier) { - if (!enabled || !testIdentifier.isTest) { - return - } - - val testId = testIdentifier.toTestIdentifier() - log.info("Test started: {}#{}", testId.className, testId.methodName) - sampler?.setCurrentTest(testId) - } - - override fun executionFinished(testIdentifier: JUnitTestIdentifier, testExecutionResult: TestExecutionResult) { - if (!enabled || !testIdentifier.isTest) { - return - } - - log.info("Test finished: {} (status: {})", testIdentifier.displayName, testExecutionResult.status) - sampler?.clearCurrentTest() - } - - override fun testPlanExecutionFinished(testPlan: TestPlan) { - log.info("testPlanExecutionFinished called, enabled={}", enabled) - if (!enabled) { - return - } - - val samples: List = sampler?.stop() ?: emptyList() - log.info("Stack sampler stopped, collected {} samples", samples.size) - - val output = outputFile - val samplingConfig = config - if (output != null && samplingConfig != null) { - log.info("Writing samples to: {}", output) - try { - RawSamplesWriter.write( - outputPath = Paths.get(output), - samples = samples, - config = samplingConfig, - ) - log.info("Successfully wrote {} samples to {}", samples.size, output) - } catch (e: Exception) { - log.error("Failed to write samples to {}: {}", output, e.message, e) - } - } else { - log.warn("Cannot write samples: outputFile={}, config={}", output, samplingConfig) - } - } - - private fun buildConfig(): SamplingConfig { - val intervalMs = System.getProperty(PROP_INTERVAL_MS)?.toLongOrNull() - ?: SamplingConfig.DEFAULT_INTERVAL_MS - val maxDepth = System.getProperty(PROP_MAX_DEPTH)?.toIntOrNull() - ?: SamplingConfig.DEFAULT_MAX_DEPTH - val excludePrefixes = System.getProperty(PROP_EXCLUDE_PREFIXES) - ?.split(",") - ?.filter { it.isNotBlank() } - ?.toSet() - ?: SamplingConfig.DEFAULT_EXCLUDES - - return SamplingConfig( - intervalMs = intervalMs, - maxDepth = maxDepth, - excludePackagePrefixes = excludePrefixes, - ) - } - - private fun JUnitTestIdentifier.toTestIdentifier(): TestIdentifier { - val source = source.orElse(null) - val className = extractClassName(source) - val methodName = extractMethodName(source) - - return TestIdentifier( - className = className, - methodName = methodName, - displayName = displayName, - ) - } - - private fun extractClassName(source: Any?): String { - if (source == null) return UNKNOWN_CLASS - - return try { - val methodSource = source as? org.junit.platform.engine.support.descriptor.MethodSource - methodSource?.className ?: UNKNOWN_CLASS - } catch (_: Exception) { - UNKNOWN_CLASS - } - } - - private fun extractMethodName(source: Any?): String { - if (source == null) return UNKNOWN_METHOD - - return try { - val methodSource = source as? org.junit.platform.engine.support.descriptor.MethodSource - methodSource?.methodName ?: UNKNOWN_METHOD - } catch (_: Exception) { - UNKNOWN_METHOD - } - } - - companion object { - private val log = LoggerFactory.getLogger(SamplingTestListener::class.java) - - private const val PROP_ENABLED = "delta.coverage.sampling.enabled" - private const val PROP_INTERVAL_MS = "delta.coverage.sampling.intervalMs" - private const val PROP_MAX_DEPTH = "delta.coverage.sampling.maxDepth" - private const val PROP_EXCLUDE_PREFIXES = "delta.coverage.sampling.excludePrefixes" - private const val PROP_OUTPUT_FILE = "delta.coverage.sampling.outputFile" - - private const val UNKNOWN_CLASS = "" - private const val UNKNOWN_METHOD = "" - } -} From 662c0e8ebb60123ac983897eb8451239d3cf477f Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Mon, 2 Feb 2026 22:27:36 +0200 Subject: [PATCH 03/15] now we can collect stacktraces using jfr and match tests with the stacktraces --- .../gradle/DeltaCoverageConfiguration.kt | 2 +- .../deltacoverage/gradle/DeltaCoveragePlugin.kt | 2 +- .../{ => test}/sampling/TestEventsCollector.kt | 2 +- .../{ => test}/sampling/TestMappingConfiguration.kt | 2 +- .../{ => test}/sampling/TestMappingIntegration.kt | 13 ++----------- 5 files changed, 6 insertions(+), 15 deletions(-) rename delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/{ => test}/sampling/TestEventsCollector.kt (95%) rename delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/{ => test}/sampling/TestMappingConfiguration.kt (91%) rename delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/{ => test}/sampling/TestMappingIntegration.kt (89%) diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt index 92512344..2b563fc7 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt @@ -1,7 +1,7 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.dsl.view.view -import io.github.surpsg.deltacoverage.gradle.sampling.TestMappingConfiguration +import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingConfiguration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty import io.github.surpsg.deltacoverage.gradle.utils.doubleProperty diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt index 17f22004..ceee9a9c 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt @@ -2,7 +2,7 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.autoapply.CoverageEngineAutoApply import io.github.surpsg.deltacoverage.gradle.reportview.ViewLookup -import io.github.surpsg.deltacoverage.gradle.sampling.TestMappingIntegration +import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingIntegration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTask import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.task.NativeGitDiffTask diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt similarity index 95% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt rename to delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt index b06e2bff..9294dd55 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestEventsCollector.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle.sampling +package io.github.surpsg.deltacoverage.gradle.test.sampling import org.gradle.api.tasks.testing.TestDescriptor import org.gradle.api.tasks.testing.TestListener diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt similarity index 91% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt rename to delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt index a93a7ec4..d8e45257 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle.sampling +package io.github.surpsg.deltacoverage.gradle.test.sampling import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty import org.gradle.api.model.ObjectFactory diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt similarity index 89% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt rename to delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt index 9b86d83c..084a6ca1 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/sampling/TestMappingIntegration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle.sampling +package io.github.surpsg.deltacoverage.gradle.test.sampling import io.github.surpsg.deltacoverage.gradle.task.TestMappingAnalysisTask import io.github.surpsg.deltacoverage.gradle.utils.deltaCoverageConfig @@ -75,15 +75,6 @@ internal object TestMappingIntegration { } } } - - // Make deltaCoverage tasks finalized by analysis task - project.afterEvaluate { - if (config.enabled.get()) { - project.tasks.matching { it.name.startsWith("deltaCoverage") }.all { deltaCoverageTask -> - deltaCoverageTask.finalizedBy(analyzeTask) - } - } - } } private fun configureTestTask(testTask: Test) { @@ -111,6 +102,6 @@ internal object TestMappingIntegration { private fun createJfcConfigFile(jfcFile: File) { jfcFile.parentFile?.mkdirs() jfcFile.writeText(JFC_CONFIG) - log.debug("Created JFC config file: ${jfcFile.absolutePath}") + log.debug("Created JFC config file: {}", jfcFile.absolutePath) } } From 8a28769bb5c4bee85c9007d3f8a6acb2f3ab3a12 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Tue, 3 Feb 2026 23:51:30 +0200 Subject: [PATCH 04/15] now we can collect stacktraces using jfr and match tests with the stacktraces --- .../gradle/TestMappingFunctionalTest.kt | 2 - .../gradle/task/TestMappingAnalysisTask.kt | 56 ++++++++++++++----- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt index e2b10d01..816dbf5e 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -65,8 +65,6 @@ class TestMappingFunctionalTest { testEventsFiles.first().readText() shouldContain "Class1Test" // Check analysis task output - result.output shouldContain "Matched stacktraces" - println(result.output) } } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt index 83245a92..cbcec935 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt @@ -1,5 +1,7 @@ package io.github.surpsg.deltacoverage.gradle.task +import jdk.jfr.consumer.RecordedEvent +import jdk.jfr.consumer.RecordedFrame import jdk.jfr.consumer.RecordedStackTrace import jdk.jfr.consumer.RecordingFile import org.gradle.api.DefaultTask @@ -33,8 +35,7 @@ abstract class TestMappingAnalysisTask : DefaultTask() { logger.lifecycle(it) } - val matchedCount = analyzeJfrFiles(testClasses) - logger.lifecycle("Matched stacktraces containing test classes: $matchedCount") + analyzeJfrFiles(testClasses) } private fun loadTestClasses(): Set { @@ -45,32 +46,57 @@ abstract class TestMappingAnalysisTask : DefaultTask() { .toSet() } - private fun analyzeJfrFiles(testClasses: Set): Int { - var count = 0 + private fun analyzeJfrFiles(testClasses: Set) { jfrFiles.files .filter { it.exists() } .forEach { jfrFile -> logger.info("Analyzing JFR file: ${jfrFile.absolutePath}") - try { + val associations: Map> = try { RecordingFile.readAllEvents(jfrFile.toPath()) + .asSequence() .filter { it.eventType.name == "jdk.ExecutionSample" } - .forEach { event -> - val stackTrace = event.stackTrace - if (stackTrace != null && containsTestClass(stackTrace, testClasses)) { - count++ + .flatMap { event: RecordedEvent -> + resolveTestToMethodMapping(testClasses, event.stackTrace).entries.asSequence() + } + .fold(mutableMapOf()) { aggMap, entry -> + aggMap.merge(entry.key, entry.value) { prev, current -> + prev + current } + aggMap } } catch (e: Exception) { logger.warn("Failed to read JFR file ${jfrFile.absolutePath}: ${e.message}") + emptyMap() + } + + associations.forEach { (test, classes) -> + println("Test: ${test} (number=${classes.size})") + classes.forEach { + println("\t$it") + } + println("===============") } } - return count } - private fun containsTestClass(stackTrace: RecordedStackTrace, testClasses: Set): Boolean { - return stackTrace.frames.any { frame -> - val className = frame.method.type.name - testClasses.any { testClass -> className.contains(testClass) } - } + private fun resolveTestToMethodMapping( + testClasses: Set, + stackTrace: RecordedStackTrace, + ): Map> { + val frames: Sequence = stackTrace.frames.reversed().asSequence() + return testClasses + .associateWith { testClass -> + frames + .dropWhile { frame -> !frame.method.type.name.contains(testClass) } + .filter { frame -> !frame.method.type.name.contains(testClass) } + .take(MAX_CALL_DEPS) + .mapIndexed { index, frame -> "[Depth=${index + 1}] ${frame.method.type.name}#${frame.method.name}:${frame.lineNumber}" } + .toSet() + } + .filterValues { it.isNotEmpty() } + } + + private companion object { + private const val MAX_CALL_DEPS = 2 } } From 87906af8b3d2263036b423839e1ff552ba60abf6 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Wed, 4 Feb 2026 20:29:30 +0200 Subject: [PATCH 05/15] generate json report --- .../gradle/TestMappingFunctionalTest.kt | 31 ++- .../src/main/java/com/java/test/Class1.java | 7 +- .../gradle/task/TestMappingAnalysisTask.kt | 97 +++---- .../test/sampling/JfrTestMappingAnalyzer.kt | 257 ++++++++++++++++++ .../test/sampling/TestMappingIntegration.kt | 4 + 5 files changed, 328 insertions(+), 68 deletions(-) create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt index 816dbf5e..a9c8743f 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -6,7 +6,12 @@ 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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue import io.kotest.matchers.collections.shouldNotBeEmpty +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 @@ -48,7 +53,7 @@ class TestMappingFunctionalTest { ) // WHEN - val result = gradleRunner.runTask("test", "analyzeTestMapping") + gradleRunner.runTask("test", "analyzeTestMapping") // THEN // Check JFR file exists @@ -64,7 +69,27 @@ class TestMappingFunctionalTest { testEventsFiles.shouldNotBeEmpty() testEventsFiles.first().readText() shouldContain "Class1Test" - // Check analysis task output - println(result.output) + // Check JSON report file + val jsonFile = rootProjectDir.resolve("build/reports/delta-coverage/test-mapping.json") + jsonFile.exists() shouldBe true + + println(jsonFile.readText()) + + val report: Map = jacksonObjectMapper().readValue(jsonFile) + report["version"] shouldBe 1 + report["generatedAt"] shouldNotBe null + + @Suppress("UNCHECKED_CAST") + val summary = report["summary"] as Map + summary["totalTests"] shouldBe 1 + (summary["totalMethods"] as Int) shouldBeGreaterThan 0 + (summary["totalSamples"] as Int) shouldBeGreaterThan 0 + + @Suppress("UNCHECKED_CAST") + val mappings = report["mappings"] as Map + mappings.keys.shouldNotBeEmpty() + + // Verify output contains Class1 (the production code) + mappings.keys.any { it.contains("Class1") } shouldBe true } } 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/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt index cbcec935..1632bede 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt @@ -1,17 +1,20 @@ package io.github.surpsg.deltacoverage.gradle.task -import jdk.jfr.consumer.RecordedEvent -import jdk.jfr.consumer.RecordedFrame -import jdk.jfr.consumer.RecordedStackTrace -import jdk.jfr.consumer.RecordingFile +import groovy.json.JsonOutput +import io.github.surpsg.deltacoverage.gradle.test.sampling.AnalyzerConfig +import io.github.surpsg.deltacoverage.gradle.test.sampling.JfrTestMappingAnalyzer import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction /** - * Task that analyzes JFR recordings and matches stack traces with test classes. + * Task that analyzes JFR recordings and generates test-to-code mapping report. */ abstract class TestMappingAnalysisTask : DefaultTask() { @@ -23,19 +26,29 @@ abstract class TestMappingAnalysisTask : DefaultTask() { @get:Optional abstract val testEventsFiles: ConfigurableFileCollection + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @get:Input + @get:Optional + abstract val includePackages: ListProperty + @TaskAction fun analyze() { val testClasses = loadTestClasses() if (testClasses.isEmpty()) { logger.lifecycle("No test classes found in test-events files") - return - } - logger.lifecycle("Loaded ${testClasses.size} test classes:") - testClasses.forEach { - logger.lifecycle(it) + } else { + logger.lifecycle("Loaded ${testClasses.size} test classes") } - analyzeJfrFiles(testClasses) + val config = AnalyzerConfig( + includePackages = includePackages.getOrElse(emptyList()) + ) + val analyzer = JfrTestMappingAnalyzer(config) + val report = analyzer.analyze(jfrFiles.files, testClasses) + + writeReport(report) } private fun loadTestClasses(): Set { @@ -46,57 +59,15 @@ abstract class TestMappingAnalysisTask : DefaultTask() { .toSet() } - private fun analyzeJfrFiles(testClasses: Set) { - jfrFiles.files - .filter { it.exists() } - .forEach { jfrFile -> - logger.info("Analyzing JFR file: ${jfrFile.absolutePath}") - val associations: Map> = try { - RecordingFile.readAllEvents(jfrFile.toPath()) - .asSequence() - .filter { it.eventType.name == "jdk.ExecutionSample" } - .flatMap { event: RecordedEvent -> - resolveTestToMethodMapping(testClasses, event.stackTrace).entries.asSequence() - } - .fold(mutableMapOf()) { aggMap, entry -> - aggMap.merge(entry.key, entry.value) { prev, current -> - prev + current - } - aggMap - } - } catch (e: Exception) { - logger.warn("Failed to read JFR file ${jfrFile.absolutePath}: ${e.message}") - emptyMap() - } - - associations.forEach { (test, classes) -> - println("Test: ${test} (number=${classes.size})") - classes.forEach { - println("\t$it") - } - println("===============") - } - } - } - - private fun resolveTestToMethodMapping( - testClasses: Set, - stackTrace: RecordedStackTrace, - ): Map> { - val frames: Sequence = stackTrace.frames.reversed().asSequence() - return testClasses - .associateWith { testClass -> - frames - .dropWhile { frame -> !frame.method.type.name.contains(testClass) } - .filter { frame -> !frame.method.type.name.contains(testClass) } - .take(MAX_CALL_DEPS) - .mapIndexed { index, frame -> "[Depth=${index + 1}] ${frame.method.type.name}#${frame.method.name}:${frame.lineNumber}" } - .toSet() - } - .filterValues { it.isNotEmpty() } - } + private fun writeReport(report: io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingReport) { + val file = outputFile.get().asFile + file.parentFile?.mkdirs() + file.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(report.toMap()))) - private companion object { - private const val MAX_CALL_DEPS = 2 + logger.lifecycle("Test mapping analysis complete:") + logger.lifecycle(" Total tests: ${report.summary.totalTests}") + logger.lifecycle(" Total methods: ${report.summary.totalMethods}") + logger.lifecycle(" Total samples: ${report.summary.totalSamples}") + logger.lifecycle(" Output: ${file.absolutePath}") } -} +} \ No newline at end of file diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt new file mode 100644 index 00000000..3754da5f --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt @@ -0,0 +1,257 @@ +package io.github.surpsg.deltacoverage.gradle.test.sampling + +import jdk.jfr.consumer.RecordedStackTrace +import jdk.jfr.consumer.RecordingFile +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +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 (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 = 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 -> + val className = frame.method.type.name + !isExcludedPackage(className) && matchesIncludePattern(className) + } + .forEachIndexed { index, frame -> + val methodKey = MethodKey( + className = frame.method.type.name, + methodName = frame.method.name, + 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) } + } + + 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) -> + methodKey.methodName to MethodMapping( + 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) -> + HotMethod( + method = "${methodKey.className}#${methodKey.methodName}", + 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 + ), + mappings = mappings, + hotMethods = hotMethods + ) + } + + private data class MethodKey( + val className: String, + val methodName: String, + 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" + ) + } +} + +/** + * Configuration for the JFR analyzer. + */ +data class AnalyzerConfig( + val maxCallDepth: Int = 20, + val topHotMethodsCount: Int = 20, + val includePackages: List = emptyList() +) + +/** + * Complete test mapping report. + */ +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 + ), + "mappings" to mappings.mapValues { (_, methods) -> + methods.mapValues { (_, mapping) -> + mapOf( + "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 + ) + } + ) +} + +data class ReportSummary( + val totalTests: Int, + val totalMethods: Int, + val totalSamples: Int +) + +data class MethodMapping( + 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 +) \ No newline at end of file diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt index 084a6ca1..64dba07a 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt @@ -17,6 +17,7 @@ internal object TestMappingIntegration { private const val JFR_FILENAME = "recording.jfr" private const val JFC_FILENAME = "stacktrace-sampling.jfc" private const val TEST_EVENTS_FILENAME = "test-events.txt" + private const val OUTPUT_FILENAME = "test-mapping.json" /** * JFC configuration optimized for stack trace sampling. @@ -57,6 +58,9 @@ internal object TestMappingIntegration { ) { task -> task.group = "verification" task.description = "Analyzes JFR recordings to map tests to code" + task.outputFile.set( + project.layout.buildDirectory.file("reports/delta-coverage/$OUTPUT_FILENAME") + ) } // Configure test tasks in root project only (POC simplification) From 9db9838448d4a2eb4f2cd65e243a63fd9c4ed701 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Wed, 4 Feb 2026 20:53:26 +0200 Subject: [PATCH 06/15] enrich report with visibility, and signature, filtered out private methods --- .../gradle/TestMappingFunctionalTest.kt | 1 + .../gradle/task/TestMappingAnalysisTask.kt | 7 +- .../test/sampling/JfrTestMappingAnalyzer.kt | 109 ++++++++++++++++-- .../test/sampling/TestMappingConfiguration.kt | 21 ++++ .../test/sampling/TestMappingIntegration.kt | 2 + 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt index a9c8743f..7b2f1d20 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -47,6 +47,7 @@ class TestMappingFunctionalTest { diffSource.file.set('$diffFilePath') testMapping { enabled = true + includePackages = ['com.java.test'] } } """.trimIndent() diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt index 1632bede..b6c43ff5 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt @@ -33,6 +33,10 @@ abstract class TestMappingAnalysisTask : DefaultTask() { @get:Optional abstract val includePackages: ListProperty + @get:Input + @get:Optional + abstract val excludePackages: ListProperty + @TaskAction fun analyze() { val testClasses = loadTestClasses() @@ -43,7 +47,8 @@ abstract class TestMappingAnalysisTask : DefaultTask() { } val config = AnalyzerConfig( - includePackages = includePackages.getOrElse(emptyList()) + includePackages = includePackages.getOrElse(emptyList()), + excludePackages = excludePackages.getOrElse(emptyList()) ) val analyzer = JfrTestMappingAnalyzer(config) val report = analyzer.analyze(jfrFiles.files, testClasses) diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt index 3754da5f..d1c435a9 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt @@ -1,10 +1,13 @@ package io.github.surpsg.deltacoverage.gradle.test.sampling +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 /** @@ -60,20 +63,25 @@ internal class JfrTestMappingAnalyzer( val testFrameIndex = frames.indexOfFirst { it.method.type.name.contains(testClass) } if (testFrameIndex == -1) continue - val testFrame = frames[testFrameIndex] + 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 = frame.method.type.name, - methodName = frame.method.name, + className = method.type.name, + methodName = method.name, + descriptor = method.descriptor, + modifiers = method.modifiers, lineNumber = frame.lineNumber ) val depth = index + 1 @@ -93,7 +101,8 @@ internal class JfrTestMappingAnalyzer( } private fun isExcludedPackage(className: String): Boolean { - return EXCLUDED_PACKAGES.any { className.startsWith(it) } + return EXCLUDED_PACKAGES.any { className.startsWith(it) } || + config.excludePackages.any { className.startsWith(it) } } private fun matchesIncludePattern(className: String): Boolean { @@ -109,7 +118,10 @@ internal class JfrTestMappingAnalyzer( .groupBy { it.key.className } .mapValues { (_, entries) -> entries.associate { (methodKey, testHits) -> - methodKey.methodName to MethodMapping( + 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 @@ -128,8 +140,9 @@ internal class JfrTestMappingAnalyzer( // Build hot methods list val hotMethods = methodMappings.entries .map { (methodKey, testHits) -> + val methodWithSignature = "${methodKey.methodName}${descriptorToSignature(methodKey.descriptor)}" HotMethod( - method = "${methodKey.className}#${methodKey.methodName}", + method = "${methodKey.className}#$methodWithSignature", totalHits = testHits.values.sumOf { it.samples }, testCount = testHits.size ) @@ -153,6 +166,8 @@ internal class JfrTestMappingAnalyzer( private data class MethodKey( val className: String, val methodName: String, + val descriptor: String, + val modifiers: Int, val lineNumber: Int ) @@ -177,6 +192,79 @@ internal class JfrTestMappingAnalyzer( "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 + } + + 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 + } } } @@ -186,7 +274,8 @@ internal class JfrTestMappingAnalyzer( data class AnalyzerConfig( val maxCallDepth: Int = 20, val topHotMethodsCount: Int = 20, - val includePackages: List = emptyList() + val includePackages: List = emptyList(), + val excludePackages: List = emptyList() ) /** @@ -210,6 +299,8 @@ data class TestMappingReport( "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 -> @@ -239,6 +330,8 @@ data class ReportSummary( ) data class MethodMapping( + val signature: String, + val visibility: String, val lineNumber: Int, val totalHits: Int, val tests: List @@ -254,4 +347,4 @@ data class HotMethod( val method: String, val totalHits: Int, val testCount: Int -) \ No newline at end of file +) diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt index d8e45257..890f8d78 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt @@ -2,6 +2,7 @@ package io.github.surpsg.deltacoverage.gradle.test.sampling import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty 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 javax.inject.Inject @@ -14,6 +15,8 @@ import javax.inject.Inject * deltaCoverageReport { * testMapping { * enabled = true + * includePackages.set(listOf("com.example")) + * excludePackages.addAll("org.springframework", "com.fasterxml") * } * } * ``` @@ -27,4 +30,22 @@ open class TestMappingConfiguration @Inject constructor( */ @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()) } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt index 64dba07a..9e0e467f 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt @@ -61,6 +61,8 @@ internal object TestMappingIntegration { task.outputFile.set( project.layout.buildDirectory.file("reports/delta-coverage/$OUTPUT_FILENAME") ) + task.includePackages.set(config.includePackages) + task.excludePackages.set(config.excludePackages) } // Configure test tasks in root project only (POC simplification) From a0290065160f1a143165b6c6b096d364ec586f2e Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Sat, 7 Feb 2026 07:54:26 +0200 Subject: [PATCH 07/15] print console report --- .../gradle/TestMappingFunctionalTest.kt | 10 ++++------ .../gradle/task/TestMappingAnalysisTask.kt | 16 +++++++++------- .../test/sampling/JfrTestMappingAnalyzer.kt | 9 ++++++--- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt index 7b2f1d20..ff9c6872 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt @@ -18,7 +18,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File -@GradlePluginTest(TestProjects.SINGLE_MODULE, kts = false) +@GradlePluginTest(TestProjects.SINGLE_MODULE) class TestMappingFunctionalTest { @RootProjectDir @@ -27,7 +27,7 @@ class TestMappingFunctionalTest { @ProjectFile("test.diff.file") lateinit var diffFilePath: String - @ProjectFile("build.gradle") + @ProjectFile("build.gradle.kts") lateinit var buildFile: RestorableFile @GradleRunnerInstance @@ -44,10 +44,10 @@ class TestMappingFunctionalTest { buildFile.file.appendText( """ deltaCoverageReport { - diffSource.file.set('$diffFilePath') + diffSource.file.set("$diffFilePath") testMapping { enabled = true - includePackages = ['com.java.test'] + includePackages.add("com.java.test") } } """.trimIndent() @@ -74,8 +74,6 @@ class TestMappingFunctionalTest { val jsonFile = rootProjectDir.resolve("build/reports/delta-coverage/test-mapping.json") jsonFile.exists() shouldBe true - println(jsonFile.readText()) - val report: Map = jacksonObjectMapper().readValue(jsonFile) report["version"] shouldBe 1 report["generatedAt"] shouldNotBe null diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt index b6c43ff5..9f483d6b 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt @@ -2,7 +2,9 @@ package io.github.surpsg.deltacoverage.gradle.task import groovy.json.JsonOutput import io.github.surpsg.deltacoverage.gradle.test.sampling.AnalyzerConfig +import io.github.surpsg.deltacoverage.gradle.test.sampling.ConsoleTestMappingReporter import io.github.surpsg.deltacoverage.gradle.test.sampling.JfrTestMappingAnalyzer +import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingReport import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty @@ -64,15 +66,15 @@ abstract class TestMappingAnalysisTask : DefaultTask() { .toSet() } - private fun writeReport(report: io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingReport) { + private fun writeReport(report: TestMappingReport) { val file = outputFile.get().asFile file.parentFile?.mkdirs() file.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(report.toMap()))) - logger.lifecycle("Test mapping analysis complete:") - logger.lifecycle(" Total tests: ${report.summary.totalTests}") - logger.lifecycle(" Total methods: ${report.summary.totalMethods}") - logger.lifecycle(" Total samples: ${report.summary.totalSamples}") - logger.lifecycle(" Output: ${file.absolutePath}") + // Print console report + val consoleReport = ConsoleTestMappingReporter.render(report) + logger.lifecycle(consoleReport) + + logger.lifecycle("JSON report: file://${file.absolutePath}") } -} \ No newline at end of file +} diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt index d1c435a9..70f3e22d 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt @@ -156,7 +156,8 @@ internal class JfrTestMappingAnalyzer( summary = ReportSummary( totalTests = testClasses.size, totalMethods = methodMappings.size, - totalSamples = result.totalSamples + totalSamples = result.totalSamples, + maxCallDepth = config.maxCallDepth ), mappings = mappings, hotMethods = hotMethods @@ -294,7 +295,8 @@ data class TestMappingReport( "summary" to mapOf( "totalTests" to summary.totalTests, "totalMethods" to summary.totalMethods, - "totalSamples" to summary.totalSamples + "totalSamples" to summary.totalSamples, + "maxCallDepth" to summary.maxCallDepth ), "mappings" to mappings.mapValues { (_, methods) -> methods.mapValues { (_, mapping) -> @@ -326,7 +328,8 @@ data class TestMappingReport( data class ReportSummary( val totalTests: Int, val totalMethods: Int, - val totalSamples: Int + val totalSamples: Int, + val maxCallDepth: Int ) data class MethodMapping( From 1c59c05892565bcf94fc1778b9704294aab75ef3 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Sat, 7 Feb 2026 07:55:32 +0200 Subject: [PATCH 08/15] console report --- .../sampling/ConsoleTestMappingReporter.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt new file mode 100644 index 00000000..05a9dbe2 --- /dev/null +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt @@ -0,0 +1,111 @@ +package io.github.surpsg.deltacoverage.gradle.test.sampling + +/** + * Renders test mapping report to console with human-readable formatting. + */ +internal object ConsoleTestMappingReporter { + + private const val LINE_WIDTH = 80 + private const val TOP_METHODS_COUNT = 10 + + fun render(report: TestMappingReport): String = buildString { + appendLine() + appendHeader("Test Mapping Report") + appendLine() + + appendSummary(report.summary) + appendLine() + + if (report.hotMethods.isNotEmpty()) { + appendHotMethods(report.hotMethods) + appendLine() + } + + appendMethodDetails(report.mappings) + appendLine() + + appendLine("─".repeat(LINE_WIDTH)) + } + + private fun StringBuilder.appendHeader(title: String) { + appendLine("─".repeat(LINE_WIDTH)) + appendLine(" $title") + appendLine("─".repeat(LINE_WIDTH)) + } + + private fun StringBuilder.appendSummary(summary: ReportSummary) { + appendLine(" Summary") + appendLine(" Methods: ${summary.totalMethods}") + appendLine(" Tests: ${summary.totalTests}") + appendLine(" Samples: ${summary.totalSamples}") + } + + private fun StringBuilder.appendHotMethods(hotMethods: List) { + appendLine(" Hot Methods (most sampled)") + hotMethods.take(TOP_METHODS_COUNT).forEach { hot -> + val method = formatMethodName(hot.method) + val stats = "${hot.totalHits} hits, ${hot.testCount} tests" + appendLine(" $method") + appendLine(" $stats") + } + } + + private fun StringBuilder.appendMethodDetails(mappings: Map>) { + appendLine(" Method Coverage Details") + + mappings.entries + .sortedBy { it.key } + .forEach { (className, methods) -> + appendLine() + appendLine(" ${formatClassName(className)}") + + methods.entries + .sortedByDescending { it.value.totalHits } + .forEach { (methodName, mapping) -> + appendMethodMapping(methodName, mapping) + } + } + } + + private fun StringBuilder.appendMethodMapping(methodName: String, mapping: MethodMapping) { + val visibility = mapping.visibility.take(3) // pub/pri/pro/pac + val hitsInfo = "${mapping.totalHits} hits" + val testsCount = "${mapping.tests.size} tests" + + appendLine(" [$visibility] $methodName ($hitsInfo, $testsCount)") + + mapping.tests + .sortedByDescending { it.samples } + .forEach { test -> + val testName = formatTestName(test.id) + val depth = "d:${test.depth}" + val samples = "${test.samples} samples" + appendLine(" ├─ $testName ($depth, $samples)") + } + } + + private fun formatClassName(className: String): String { + return className.substringAfterLast('.') + .let { "[$it]" } + .let { "$className".replace(className.substringAfterLast('.'), it) } + .let { className } + } + + private fun formatMethodName(fullMethod: String): String { + val parts = fullMethod.split("#") + if (parts.size != 2) return fullMethod + + val className = parts[0].substringAfterLast('.') + val methodName = parts[1] + return "$className#$methodName" + } + + private fun formatTestName(testId: String): String { + val parts = testId.split("#") + if (parts.size != 2) return testId + + val className = parts[0].substringAfterLast('.') + val methodName = parts[1] + return "$className.$methodName" + } +} \ No newline at end of file From 7b425427a851972a6fc57fe405a646860f2cdbc7 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Fri, 13 Feb 2026 00:19:44 +0200 Subject: [PATCH 09/15] extracted separate plugin --- build.gradle.kts | 7 - delta-coverage-gradle/build.gradle.kts | 2 +- .../gradle/DeltaCoverageConfiguration.kt | 8 -- .../gradle/DeltaCoveragePlugin.kt | 2 - .../test/sampling/TestMappingIntegration.kt | 113 ---------------- settings.gradle.kts | 1 + test-impact-gradle/build.gradle.kts | 27 ++++ .../gradle/TestMappingFunctionalTest.kt | 26 ++-- .../build.gradle.kts | 26 ++++ .../src/main/java/com/java/test/Class1.java | 28 ++++ .../java/com/java/test/UnchagedClass.java | 6 + .../test/java/com/java/test/Class1Test.java | 28 ++++ .../single-module-test-project/test.diff.file | 33 +++++ .../testimpact/gradle/TestImpactPlugin.kt | 127 ++++++++++++++++++ .../gradle/config/TestImpactConfiguration.kt | 16 +-- .../analysis}/JfrTestMappingAnalyzer.kt | 9 +- .../report}/ConsoleTestMappingReporter.kt | 21 ++- .../gradle/task/TestMappingAnalysisTask.kt | 31 ++--- .../test/listener}/TestEventsCollector.kt | 2 +- .../gwkit/testimpact/gradle/utils/Objects.kt | 10 ++ 20 files changed, 333 insertions(+), 190 deletions(-) delete mode 100644 delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt create mode 100644 test-impact-gradle/build.gradle.kts rename {delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage => test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact}/gradle/TestMappingFunctionalTest.kt (82%) create mode 100644 test-impact-gradle/src/functionalTest/resources/single-module-test-project/build.gradle.kts create mode 100644 test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/Class1.java create mode 100644 test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/main/java/com/java/test/UnchagedClass.java create mode 100644 test-impact-gradle/src/functionalTest/resources/single-module-test-project/src/test/java/com/java/test/Class1Test.java create mode 100644 test-impact-gradle/src/functionalTest/resources/single-module-test-project/test.diff.file create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt rename delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt => test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt (75%) rename {delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling => test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis}/JfrTestMappingAnalyzer.kt (97%) rename {delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling => test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report}/ConsoleTestMappingReporter.kt (84%) rename {delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage => test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact}/gradle/task/TestMappingAnalysisTask.kt (65%) rename {delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling => test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener}/TestEventsCollector.kt (95%) create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/utils/Objects.kt diff --git a/build.gradle.kts b/build.gradle.kts index a26582a1..39c61df6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,11 +13,4 @@ deltaCoverageReport { excludeClasses.addAll( "**/deltacoverage/demo/*" ) - testMapping { - enabled = true - sampling { - intervalMs = 1 - maxDepth = 50 - } - } } diff --git a/delta-coverage-gradle/build.gradle.kts b/delta-coverage-gradle/build.gradle.kts index 3c73bf8e..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 { diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt index 2b563fc7..b79bad29 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoverageConfiguration.kt @@ -1,7 +1,6 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.dsl.view.view -import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingConfiguration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty import io.github.surpsg.deltacoverage.gradle.utils.doubleProperty @@ -51,9 +50,6 @@ open class DeltaCoverageConfiguration @Inject constructor( @Nested val reportConfiguration: ReportsConfiguration = ReportsConfiguration(objectFactory) - @Nested - val testMapping: TestMappingConfiguration = objectFactory.new() - @Internal val reportViews: NamedDomainObjectContainer = objectFactory.domainObjectContainer(ReportView::class.java) { name -> @@ -72,10 +68,6 @@ open class DeltaCoverageConfiguration @Inject constructor( action.execute(diffSource) } - fun testMapping(action: Action) { - action.execute(testMapping) - } - /** * Configures a [ReportView] for the report. * If the view is not found, it will be created. diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt index ceee9a9c..64845866 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt +++ b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/DeltaCoveragePlugin.kt @@ -2,7 +2,6 @@ package io.github.surpsg.deltacoverage.gradle import io.github.surpsg.deltacoverage.gradle.autoapply.CoverageEngineAutoApply import io.github.surpsg.deltacoverage.gradle.reportview.ViewLookup -import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingIntegration import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTask import io.github.surpsg.deltacoverage.gradle.task.DeltaCoverageTaskConfigurer import io.github.surpsg.deltacoverage.gradle.task.NativeGitDiffTask @@ -28,7 +27,6 @@ open class DeltaCoveragePlugin : Plugin { objects, ) CoverageEngineAutoApply().applyEngine(project) - TestMappingIntegration.configure(project) val deltaTaskForViewConfigurer: (String) -> Unit = deltaTaskForViewConfigurer() diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt b/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt deleted file mode 100644 index 9e0e467f..00000000 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingIntegration.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.github.surpsg.deltacoverage.gradle.test.sampling - -import io.github.surpsg.deltacoverage.gradle.task.TestMappingAnalysisTask -import io.github.surpsg.deltacoverage.gradle.utils.deltaCoverageConfig -import org.gradle.api.Project -import org.gradle.api.tasks.testing.Test -import org.slf4j.LoggerFactory -import java.io.File - -/** - * Integrates test-to-code mapping with test tasks using JFR for stack trace collection. - */ -internal object TestMappingIntegration { - - private val log = LoggerFactory.getLogger(TestMappingIntegration::class.java) - - private const val JFR_FILENAME = "recording.jfr" - private const val JFC_FILENAME = "stacktrace-sampling.jfc" - private const val TEST_EVENTS_FILENAME = "test-events.txt" - private const val OUTPUT_FILENAME = "test-mapping.json" - - /** - * 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() - - /** - * Configures test tasks with JFR recording and test event collection if enabled. - * For POC: only configures root project test tasks. - */ - fun configure(project: Project) { - val config = project.deltaCoverageConfig.testMapping - - // Register analysis task early - val analyzeTask = project.tasks.register( - "analyzeTestMapping", - TestMappingAnalysisTask::class.java - ) { task -> - task.group = "verification" - task.description = "Analyzes JFR recordings to map tests to code" - task.outputFile.set( - project.layout.buildDirectory.file("reports/delta-coverage/$OUTPUT_FILENAME") - ) - task.includePackages.set(config.includePackages) - task.excludePackages.set(config.excludePackages) - } - - // Configure test tasks in root project only (POC simplification) - // Using whenTaskAdded to catch all Test tasks, including those created by plugins - project.tasks.withType(Test::class.java).all { testTask -> - // Defer configuration to afterEvaluate to ensure DSL is processed - project.afterEvaluate { - if (config.enabled.get()) { - log.info("Configuring test task '${testTask.name}' for JFR recording") - configureTestTask(testTask) - analyzeTask.configure { task -> - task.jfrFiles.from(testTask.temporaryDir.resolve(JFR_FILENAME)) - task.testEventsFiles.from(testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME)) - task.dependsOn(testTask) - } - } - } - } - } - - private fun configureTestTask(testTask: Test) { - log.info("Configuring test task '${testTask.name}' for JFR recording") - - // 1. Create JFC config file in temp dir - val jfcFile = testTask.temporaryDir.resolve(JFC_FILENAME) - createJfcConfigFile(jfcFile) - - // 2. Add JFR JVM args with custom JFC settings - val jfrFile = testTask.temporaryDir.resolve(JFR_FILENAME) - testTask.jvmArgs( - "-XX:StartFlightRecording=filename=${jfrFile.absolutePath},settings=${jfcFile.absolutePath},dumponexit=true" - ) - - // 3. Add TestListener for collecting test class names - val testEventsFile = testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME) - testTask.addTestListener(TestEventsCollector(testEventsFile)) - - // 4. Register outputs for up-to-date checks - testTask.outputs.file(jfrFile) - testTask.outputs.file(testEventsFile) - } - - private fun createJfcConfigFile(jfcFile: File) { - jfcFile.parentFile?.mkdirs() - jfcFile.writeText(JFC_CONFIG) - log.debug("Created JFC config file: {}", jfcFile.absolutePath) - } -} 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..925dc51a --- /dev/null +++ b/test-impact-gradle/build.gradle.kts @@ -0,0 +1,27 @@ +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) + + // Functional tests + functionalTestImplementation(deps.jacksonKotlin) + functionalTestImplementation(deps.gradleProbe) +} diff --git a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt b/test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt similarity index 82% rename from delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt rename to test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt index ff9c6872..709e178b 100644 --- a/delta-coverage-gradle/src/functionalTest/kotlin/io/github/surpsg/deltacoverage/gradle/TestMappingFunctionalTest.kt +++ b/test-impact-gradle/src/functionalTest/kotlin/io/github/gwkit/testimpact/gradle/TestMappingFunctionalTest.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle +package io.github.gwkit.testimpact.gradle import io.github.gwkit.gradleprobe.RestorableFile import io.github.gwkit.gradleprobe.gradlerunner.runTask @@ -18,43 +18,35 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File -@GradlePluginTest(TestProjects.SINGLE_MODULE) +@GradlePluginTest("single-module-test-project") class TestMappingFunctionalTest { @RootProjectDir lateinit var rootProjectDir: File - @ProjectFile("test.diff.file") - lateinit var diffFilePath: String - @ProjectFile("build.gradle.kts") lateinit var buildFile: RestorableFile @GradleRunnerInstance lateinit var gradleRunner: GradleRunner - @BeforeEach - fun beforeEach() { - buildFile.restoreOriginContent() - } - @Test fun `test mapping should create JFR recording and test events files`() { // GIVEN buildFile.file.appendText( """ - deltaCoverageReport { - diffSource.file.set("$diffFilePath") - testMapping { - enabled = true - includePackages.add("com.java.test") - } + testImpact { + enabled = true + includePackages.add("com.java.test") } """.trimIndent() ) // WHEN gradleRunner.runTask("test", "analyzeTestMapping") + .apply { + println(output) + } // THEN // Check JFR file exists @@ -71,7 +63,7 @@ class TestMappingFunctionalTest { testEventsFiles.first().readText() shouldContain "Class1Test" // Check JSON report file - val jsonFile = rootProjectDir.resolve("build/reports/delta-coverage/test-mapping.json") + val jsonFile = rootProjectDir.resolve("build/reports/test-impact/test-mapping.json") jsonFile.exists() shouldBe true val report: Map = jacksonObjectMapper().readValue(jsonFile) 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..ea46845f --- /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 < 100; 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/TestImpactPlugin.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt new file mode 100644 index 00000000..9796dd43 --- /dev/null +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPlugin.kt @@ -0,0 +1,127 @@ +package io.github.gwkit.testimpact.gradle + +import io.github.gwkit.testimpact.gradle.config.TestImpactConfiguration +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 org.slf4j.LoggerFactory +import java.io.File + +open class TestImpactPlugin : Plugin { + + override fun apply(project: Project) { + val config = project.extensions.create( + EXTENSION_NAME, + TestImpactConfiguration::class.java, + project.objects, + ) + + val analyzeTask: TaskProvider = project.tasks.register( + ANALYZE_TASK_NAME, + TestMappingAnalysisTask::class.java + ) { task -> + task.onlyIf { + config.enabled.get() + } + task.group = "verification" + task.description = "Analyzes JFR recordings to map tests to code" + task.outputFile.set( + project.layout.buildDirectory.file("reports/test-impact/$OUTPUT_FILENAME") + ) + task.includePackages.set(config.includePackages) + task.excludePackages.set(config.excludePackages) + } + project.tasks.withType(Test::class.java).configureEach { testTask -> + configureTestTask(testTask, config) + with(analyzeTask.get()) { + val jfrFile: File = testTask.temporaryDir.resolve(JFR_FILENAME) + jfrFiles.from(jfrFile) + testEventsFiles.from(testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME)) + mustRunAfter(testTask) + } + } + } + + private fun configureTestTask(testTask: Test, config: TestImpactConfiguration) { + val jfcFile = testTask.temporaryDir.resolve(JFC_FILENAME).apply { + createJfcConfigFile(this) + } + val jfrFile = testTask.temporaryDir.resolve(JFR_FILENAME) + val testEventsFile = testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME) + + testTask.jvmArgumentProviders.add( + JfrCommandLineProvider( + config.enabled, + jfcFile, + jfrFile + ) + ) + + testTask.addTestListener(TestEventsCollector(testEventsFile)) + } + + private class JfrCommandLineProvider( + private val enabled: Provider, + private val jfcFile: File, + private val jfrFile: File + ) : CommandLineArgumentProvider { + override fun asArguments(): Iterable = if (enabled.get()) { + val jvmArg = sequenceOf( + "-XX:StartFlightRecording=filename=${jfrFile.absolutePath}", + "settings=${jfcFile.absolutePath}", + "dumponexit=true", + ).joinToString(",") + listOf(jvmArg) + } else { + emptyList() + } + } + + private fun createJfcConfigFile(jfcFile: File) { + jfcFile.parentFile?.mkdirs() + jfcFile.writeText(JFC_CONFIG) + log.debug("Created JFC config file: {}", jfcFile.absolutePath) + } + + companion object { + const val EXTENSION_NAME = "testImpact" + const val ANALYZE_TASK_NAME = "analyzeTestMapping" + + private const val JFR_FILENAME = "recording.jfr" + private const val JFC_FILENAME = "stacktrace-sampling.jfc" + private const val TEST_EVENTS_FILENAME = "test-events.txt" + private const val OUTPUT_FILENAME = "test-mapping.json" + + private val log = LoggerFactory.getLogger(TestImpactPlugin::class.java) + + /** + * 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/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt similarity index 75% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt index 890f8d78..0a0e58ce 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestMappingConfiguration.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt @@ -1,6 +1,6 @@ -package io.github.surpsg.deltacoverage.gradle.test.sampling +package io.github.gwkit.testimpact.gradle.config -import io.github.surpsg.deltacoverage.gradle.utils.booleanProperty +import io.github.gwkit.testimpact.gradle.utils.booleanProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -12,16 +12,14 @@ import javax.inject.Inject * * Example usage: * ```kotlin - * deltaCoverageReport { - * testMapping { - * enabled = true - * includePackages.set(listOf("com.example")) - * excludePackages.addAll("org.springframework", "com.fasterxml") - * } + * testImpact { + * enabled = true + * includePackages.set(listOf("com.example")) + * excludePackages.addAll("org.springframework", "com.fasterxml") * } * ``` */ -open class TestMappingConfiguration @Inject constructor( +open class TestImpactConfiguration @Inject constructor( objectFactory: ObjectFactory, ) { /** diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt similarity index 97% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt index 70f3e22d..03b73231 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/JfrTestMappingAnalyzer.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/JfrTestMappingAnalyzer.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle.test.sampling +package io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis import jdk.jfr.consumer.RecordedFrame import jdk.jfr.consumer.RecordedMethod @@ -34,7 +34,7 @@ internal class JfrTestMappingAnalyzer( jfrFiles .filter { it.exists() } .forEach { jfrFile -> - log.info("Analyzing JFR file: ${jfrFile.absolutePath}") + log.info("Analyzing JFR file: {}", jfrFile.absolutePath) try { RecordingFile.readAllEvents(jfrFile.toPath()) .asSequence() @@ -44,8 +44,8 @@ internal class JfrTestMappingAnalyzer( totalSamples++ processStackTrace(stackTrace, testClasses, methodMappings) } - } catch (e: Exception) { - log.warn("Failed to read JFR file ${jfrFile.absolutePath}: ${e.message}") + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + log.warn("Failed to read JFR file {}: {}", jfrFile.absolutePath, e.message) } } @@ -228,6 +228,7 @@ internal class JfrTestMappingAnalyzer( return params } + @Suppress("CyclomaticComplexMethod") private fun parseType(descriptor: String, startIndex: Int): Pair { var i = startIndex var arrayDepth = 0 diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt similarity index 84% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt index 05a9dbe2..09e9b56c 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/ConsoleTestMappingReporter.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt @@ -1,4 +1,9 @@ -package io.github.surpsg.deltacoverage.gradle.test.sampling +package io.github.gwkit.testimpact.gradle.sampling.testmapping.report + +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.HotMethod +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.MethodMapping +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.ReportSummary +import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.TestMappingReport /** * Renders test mapping report to console with human-readable formatting. @@ -7,6 +12,7 @@ internal object ConsoleTestMappingReporter { private const val LINE_WIDTH = 80 private const val TOP_METHODS_COUNT = 10 + private const val VISIBILITY_ABBREVIATION_LENGTH = 3 fun render(report: TestMappingReport): String = buildString { appendLine() @@ -57,7 +63,7 @@ internal object ConsoleTestMappingReporter { .sortedBy { it.key } .forEach { (className, methods) -> appendLine() - appendLine(" ${formatClassName(className)}") + appendLine(" $className") methods.entries .sortedByDescending { it.value.totalHits } @@ -68,7 +74,7 @@ internal object ConsoleTestMappingReporter { } private fun StringBuilder.appendMethodMapping(methodName: String, mapping: MethodMapping) { - val visibility = mapping.visibility.take(3) // pub/pri/pro/pac + val visibility = mapping.visibility.take(VISIBILITY_ABBREVIATION_LENGTH) val hitsInfo = "${mapping.totalHits} hits" val testsCount = "${mapping.tests.size} tests" @@ -84,13 +90,6 @@ internal object ConsoleTestMappingReporter { } } - private fun formatClassName(className: String): String { - return className.substringAfterLast('.') - .let { "[$it]" } - .let { "$className".replace(className.substringAfterLast('.'), it) } - .let { className } - } - private fun formatMethodName(fullMethod: String): String { val parts = fullMethod.split("#") if (parts.size != 2) return fullMethod @@ -108,4 +107,4 @@ internal object ConsoleTestMappingReporter { val methodName = parts[1] return "$className.$methodName" } -} \ No newline at end of file +} diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt similarity index 65% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt index 9f483d6b..b533dc2b 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/task/TestMappingAnalysisTask.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt @@ -1,10 +1,10 @@ -package io.github.surpsg.deltacoverage.gradle.task +package io.github.gwkit.testimpact.gradle.task import groovy.json.JsonOutput -import io.github.surpsg.deltacoverage.gradle.test.sampling.AnalyzerConfig -import io.github.surpsg.deltacoverage.gradle.test.sampling.ConsoleTestMappingReporter -import io.github.surpsg.deltacoverage.gradle.test.sampling.JfrTestMappingAnalyzer -import io.github.surpsg.deltacoverage.gradle.test.sampling.TestMappingReport +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.ConsoleTestMappingReporter import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.RegularFileProperty @@ -41,30 +41,27 @@ abstract class TestMappingAnalysisTask : DefaultTask() { @TaskAction fun analyze() { - val testClasses = loadTestClasses() + val testClasses: Set = loadTestClasses() if (testClasses.isEmpty()) { logger.lifecycle("No test classes found in test-events files") } else { - logger.lifecycle("Loaded ${testClasses.size} test classes") + logger.lifecycle("Loaded {} test classes", testClasses.size) } val config = AnalyzerConfig( includePackages = includePackages.getOrElse(emptyList()), excludePackages = excludePackages.getOrElse(emptyList()) ) - val analyzer = JfrTestMappingAnalyzer(config) - val report = analyzer.analyze(jfrFiles.files, testClasses) + val report: TestMappingReport = JfrTestMappingAnalyzer(config).analyze(jfrFiles.files, testClasses) writeReport(report) } - private fun loadTestClasses(): Set { - return testEventsFiles.files - .filter { it.exists() } - .flatMap { it.readLines() } - .filter { it.isNotBlank() } - .toSet() - } + private fun loadTestClasses(): Set = testEventsFiles.files + .filter { it.exists() } + .flatMap { it.readLines() } + .filter { it.isNotBlank() } + .toSet() private fun writeReport(report: TestMappingReport) { val file = outputFile.get().asFile @@ -75,6 +72,6 @@ abstract class TestMappingAnalysisTask : DefaultTask() { val consoleReport = ConsoleTestMappingReporter.render(report) logger.lifecycle(consoleReport) - logger.lifecycle("JSON report: file://${file.absolutePath}") + logger.lifecycle("JSON report: file://{}", file.absolutePath) } } diff --git a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt similarity index 95% rename from delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt index 9294dd55..2c53376e 100644 --- a/delta-coverage-gradle/src/main/kotlin/io/github/surpsg/deltacoverage/gradle/test/sampling/TestEventsCollector.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/test/listener/TestEventsCollector.kt @@ -1,4 +1,4 @@ -package io.github.surpsg.deltacoverage.gradle.test.sampling +package io.github.gwkit.testimpact.gradle.test.listener import org.gradle.api.tasks.testing.TestDescriptor import org.gradle.api.tasks.testing.TestListener 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) +} From 740c13bc69d089c4cf19626ea624db7f02f497a3 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Fri, 13 Feb 2026 00:46:37 +0200 Subject: [PATCH 10/15] refactoring --- .../github/gwkit/testimpact/gradle/TestImpactPlugin.kt | 5 +---- .../gradle/config/TestImpactConfiguration.kt | 4 ++++ .../testimpact/gradle/task/TestMappingAnalysisTask.kt | 10 +++++++++- 3 files changed, 14 insertions(+), 5 deletions(-) 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 index 9796dd43..42d538a5 100644 --- 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 @@ -28,10 +28,8 @@ open class TestImpactPlugin : Plugin { task.onlyIf { config.enabled.get() } - task.group = "verification" - task.description = "Analyzes JFR recordings to map tests to code" task.outputFile.set( - project.layout.buildDirectory.file("reports/test-impact/$OUTPUT_FILENAME") + project.layout.projectDirectory.file(config.reportOutputLocation) ) task.includePackages.set(config.includePackages) task.excludePackages.set(config.excludePackages) @@ -95,7 +93,6 @@ open class TestImpactPlugin : Plugin { private const val JFR_FILENAME = "recording.jfr" private const val JFC_FILENAME = "stacktrace-sampling.jfc" private const val TEST_EVENTS_FILENAME = "test-events.txt" - private const val OUTPUT_FILENAME = "test-mapping.json" private val log = LoggerFactory.getLogger(TestImpactPlugin::class.java) diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt index 0a0e58ce..9470fa1b 100644 --- a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt @@ -46,4 +46,8 @@ open class TestImpactConfiguration @Inject constructor( @Input val excludePackages: ListProperty = objectFactory.listProperty(String::class.java) .convention(emptyList()) + + @Input + val reportOutputLocation: Property = objectFactory.property(String::class.java) + .convention("build/reports/test-impact/test-mapping.json") } 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 index b533dc2b..761ffa6f 100644 --- 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 @@ -14,11 +14,19 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction +import javax.inject.Inject /** * Task that analyzes JFR recordings and generates test-to-code mapping report. */ -abstract class TestMappingAnalysisTask : DefaultTask() { +abstract class TestMappingAnalysisTask @Inject constructor( + +): DefaultTask() { + + init { + group = "verification" + description = "Analyzes JFR recordings to map tests to code" + } @get:InputFiles @get:Optional From 948c189314b053d4fc1f7aa3a818130f9f96c2ae Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Fri, 13 Feb 2026 01:17:07 +0200 Subject: [PATCH 11/15] refactoring --- .../testimpact/gradle/TestImpactPlugin.kt | 73 +++++++------------ .../gradle/task/GenerateJfcConfigTask.kt | 57 +++++++++++++++ 2 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/GenerateJfcConfigTask.kt 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 index 42d538a5..cbe46254 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -9,7 +10,6 @@ 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 org.slf4j.LoggerFactory import java.io.File open class TestImpactPlugin : Plugin { @@ -34,8 +34,11 @@ open class TestImpactPlugin : Plugin { task.includePackages.set(config.includePackages) task.excludePackages.set(config.excludePackages) } + + val generateJfcTask: TaskProvider = project.registerGenerateJfcTask(config) + project.tasks.withType(Test::class.java).configureEach { testTask -> - configureTestTask(testTask, config) + testTask.configureTestTask(config, generateJfcTask) with(analyzeTask.get()) { val jfrFile: File = testTask.temporaryDir.resolve(JFR_FILENAME) jfrFiles.from(jfrFile) @@ -45,33 +48,45 @@ open class TestImpactPlugin : Plugin { } } - private fun configureTestTask(testTask: Test, config: TestImpactConfiguration) { - val jfcFile = testTask.temporaryDir.resolve(JFC_FILENAME).apply { - createJfcConfigFile(this) + 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")) } - val jfrFile = testTask.temporaryDir.resolve(JFR_FILENAME) - val testEventsFile = testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME) + } + + private fun Test.configureTestTask( + config: TestImpactConfiguration, + generateJfcTask: TaskProvider, + ) { + inputs.files(generateJfcTask) + + val jfrFile = temporaryDir.resolve(JFR_FILENAME) + val testEventsFile = temporaryDir.resolve(TEST_EVENTS_FILENAME) - testTask.jvmArgumentProviders.add( + jvmArgumentProviders.add( JfrCommandLineProvider( config.enabled, - jfcFile, + generateJfcTask.flatMap { it.jfcFile }.map { it.asFile }, jfrFile ) ) - testTask.addTestListener(TestEventsCollector(testEventsFile)) + addTestListener(TestEventsCollector(testEventsFile)) } private class JfrCommandLineProvider( private val enabled: Provider, - private val jfcFile: File, + 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.absolutePath}", + "settings=${jfcFile.get().absolutePath}", "dumponexit=true", ).joinToString(",") listOf(jvmArg) @@ -80,45 +95,11 @@ open class TestImpactPlugin : Plugin { } } - private fun createJfcConfigFile(jfcFile: File) { - jfcFile.parentFile?.mkdirs() - jfcFile.writeText(JFC_CONFIG) - log.debug("Created JFC config file: {}", jfcFile.absolutePath) - } - companion object { const val EXTENSION_NAME = "testImpact" const val ANALYZE_TASK_NAME = "analyzeTestMapping" private const val JFR_FILENAME = "recording.jfr" - private const val JFC_FILENAME = "stacktrace-sampling.jfc" private const val TEST_EVENTS_FILENAME = "test-events.txt" - - private val log = LoggerFactory.getLogger(TestImpactPlugin::class.java) - - /** - * 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/GenerateJfcConfigTask.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/GenerateJfcConfigTask.kt new file mode 100644 index 00000000..59bd7ac9 --- /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() + } +} \ No newline at end of file From d72605c842c3c0bd7cab627da3569c4e66265e9c Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Sun, 1 Mar 2026 21:24:00 +0200 Subject: [PATCH 12/15] refactoring --- .../gwkit/testimpact/gradle/TestMappingFunctionalTest.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index 709e178b..8764d9cd 100644 --- 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 @@ -1,23 +1,23 @@ 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 com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import io.kotest.matchers.collections.shouldNotBeEmpty 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 { @@ -70,13 +70,12 @@ class TestMappingFunctionalTest { report["version"] shouldBe 1 report["generatedAt"] shouldNotBe null - @Suppress("UNCHECKED_CAST") + val summary = report["summary"] as Map summary["totalTests"] shouldBe 1 (summary["totalMethods"] as Int) shouldBeGreaterThan 0 (summary["totalSamples"] as Int) shouldBeGreaterThan 0 - @Suppress("UNCHECKED_CAST") val mappings = report["mappings"] as Map mappings.keys.shouldNotBeEmpty() From 173d48a8e153ce98d5c460e1fed758044d8e7eed Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Wed, 4 Mar 2026 00:28:23 +0200 Subject: [PATCH 13/15] flame graph and html --- .../gradle/TestMappingFunctionalTest.kt | 37 +++++++++++++++ .../src/main/java/com/java/test/Class1.java | 2 +- .../testimpact/gradle/TestImpactPlugin.kt | 7 ++- .../gradle/config/TestImpactConfiguration.kt | 25 ++++++++++- .../gradle/task/GenerateJfcConfigTask.kt | 2 +- .../gradle/task/TestMappingAnalysisTask.kt | 45 +++++++++++++------ 6 files changed, 98 insertions(+), 20 deletions(-) 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 index 8764d9cd..56ce82cf 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -30,6 +31,12 @@ class TestMappingFunctionalTest { @GradleRunnerInstance lateinit var gradleRunner: GradleRunner + @BeforeEach + fun beforeEach() { + buildFile.restoreOriginContent() + rootProjectDir.resolve("build/reports/test-impact").deleteRecursively() + } + @Test fun `test mapping should create JFR recording and test events files`() { // GIVEN @@ -82,4 +89,34 @@ class TestMappingFunctionalTest { // Verify output contains Class1 (the production code) mappings.keys.any { it.contains("Class1") } shouldBe true } + + @Test + fun `all report types should be created when all enabled`() { + // GIVEN + buildFile.file.appendText( + """ + testImpact { + enabled = true + includePackages.add("com.java.test") + reports { + json.set(true) + html.set(true) + flamegraph.set(true) + } + } + """.trimIndent() + ) + + // WHEN + gradleRunner.runTask("test", "analyzeTestMapping") + .apply { + println(output) + } + + // THEN + val reportDir = rootProjectDir.resolve("build/reports/test-impact") + reportDir.resolve("test-mapping.json").exists() shouldBe true + reportDir.resolve("test-mapping.html").exists() shouldBe true + reportDir.resolve("flamegraph.html").exists() shouldBe true + } } 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 index ea46845f..dcd84e82 100644 --- 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 @@ -3,7 +3,7 @@ public class Class1 { public int covered(boolean arg) { - for (int i = 0; i < 100; i++) { + for (int i = 0; i < 1000; i++) { System.out.println(i); } if (arg) { 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 index cbe46254..9c5b0bb2 100644 --- 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 @@ -28,11 +28,14 @@ open class TestImpactPlugin : Plugin { task.onlyIf { config.enabled.get() } - task.outputFile.set( - project.layout.projectDirectory.file(config.reportOutputLocation) + task.outputDirectory.set( + project.layout.projectDirectory.dir(config.reportOutputDir) ) task.includePackages.set(config.includePackages) task.excludePackages.set(config.excludePackages) + task.jsonEnabled.set(config.reports.json) + task.htmlEnabled.set(config.reports.html) + task.flamegraphEnabled.set(config.reports.flamegraph) } val generateJfcTask: TaskProvider = project.registerGenerateJfcTask(config) diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt index 9470fa1b..135b3f43 100644 --- a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt @@ -1,10 +1,13 @@ 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 /** @@ -16,6 +19,11 @@ import javax.inject.Inject * enabled = true * includePackages.set(listOf("com.example")) * excludePackages.addAll("org.springframework", "com.fasterxml") + * reports { + * json.set(true) + * html.set(true) + * flamegraph.set(true) + * } * } * ``` */ @@ -47,7 +55,20 @@ open class TestImpactConfiguration @Inject constructor( val excludePackages: ListProperty = objectFactory.listProperty(String::class.java) .convention(emptyList()) + /** + * Output directory for all reports. + * Defaults to "build/reports/test-impact". + */ @Input - val reportOutputLocation: Property = objectFactory.property(String::class.java) - .convention("build/reports/test-impact/test-mapping.json") + 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) + } } 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 index 59bd7ac9..02765900 100644 --- 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 @@ -54,4 +54,4 @@ abstract class GenerateJfcConfigTask @Inject constructor() : DefaultTask() { """.trimIndent() } -} \ No newline at end of file +} 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 index 761ffa6f..0070a400 100644 --- 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 @@ -1,23 +1,24 @@ package io.github.gwkit.testimpact.gradle.task -import groovy.json.JsonOutput 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.ConsoleTestMappingReporter +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.RegularFileProperty +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.OutputFile +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import javax.inject.Inject /** - * Task that analyzes JFR recordings and generates test-to-code mapping report. + * Task that analyzes JFR recordings and generates test-to-code mapping reports. */ abstract class TestMappingAnalysisTask @Inject constructor( @@ -36,8 +37,8 @@ abstract class TestMappingAnalysisTask @Inject constructor( @get:Optional abstract val testEventsFiles: ConfigurableFileCollection - @get:OutputFile - abstract val outputFile: RegularFileProperty + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty @get:Input @get:Optional @@ -47,6 +48,15 @@ abstract class TestMappingAnalysisTask @Inject constructor( @get:Optional abstract val excludePackages: ListProperty + @get:Input + abstract val jsonEnabled: Property + + @get:Input + abstract val htmlEnabled: Property + + @get:Input + abstract val flamegraphEnabled: Property + @TaskAction fun analyze() { val testClasses: Set = loadTestClasses() @@ -62,7 +72,7 @@ abstract class TestMappingAnalysisTask @Inject constructor( ) val report: TestMappingReport = JfrTestMappingAnalyzer(config).analyze(jfrFiles.files, testClasses) - writeReport(report) + writeReports(report) } private fun loadTestClasses(): Set = testEventsFiles.files @@ -71,15 +81,22 @@ abstract class TestMappingAnalysisTask @Inject constructor( .filter { it.isNotBlank() } .toSet() - private fun writeReport(report: TestMappingReport) { - val file = outputFile.get().asFile - file.parentFile?.mkdirs() - file.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(report.toMap()))) + private fun writeReports(report: TestMappingReport) { + val outputDir = outputDirectory.get().asFile + + val writer = ReportWriter( + outputDir = outputDir, + jsonEnabled = jsonEnabled.get(), + htmlEnabled = htmlEnabled.get(), + flamegraphEnabled = flamegraphEnabled.get(), + ) + + val generatedFiles = writer.write(report, jfrFiles.files) + generatedFiles.forEach { file -> + logger.lifecycle("Report: file://{}", file.absolutePath) + } - // Print console report val consoleReport = ConsoleTestMappingReporter.render(report) logger.lifecycle(consoleReport) - - logger.lifecycle("JSON report: file://{}", file.absolutePath) } } From fcbee6edc9e9d24d45084d94ddaa0f13d8c26b09 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Sat, 7 Mar 2026 20:15:25 +0200 Subject: [PATCH 14/15] flame graph and html --- gradle/deps.versions.toml | 2 + test-impact-gradle/build.gradle.kts | 3 + .../gradle/TestMappingFunctionalTest.kt | 32 +++++ .../{config => }/TestImpactConfiguration.kt | 25 ++++ .../testimpact/gradle/TestImpactPlugin.kt | 1 - .../testmapping/analysis/FlamegraphData.kt | 10 ++ .../analysis/FlamegraphDataCollector.kt | 89 ++++++++++++++ .../analysis/JfrTestMappingAnalyzer.kt | 6 +- .../report/AsyncProfilerFlamegraphReporter.kt | 34 ++++++ .../report/ConsoleTestMappingReporter.kt | 110 ------------------ .../report/HtmlTestMappingReporter.kt | 29 +++++ .../testmapping/report/ReportConfig.kt | 12 ++ .../testmapping/report/ReportWriter.kt | 30 +++++ .../sampling/testmapping/report/Reporter.kt | 21 ++++ .../gradle/task/TestMappingAnalysisTask.kt | 27 ++--- 15 files changed, 299 insertions(+), 132 deletions(-) rename test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/{config => }/TestImpactConfiguration.kt (76%) create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphData.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/analysis/FlamegraphDataCollector.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/AsyncProfilerFlamegraphReporter.kt delete mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/HtmlTestMappingReporter.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportConfig.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ReportWriter.kt create mode 100644 test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/Reporter.kt diff --git a/gradle/deps.versions.toml b/gradle/deps.versions.toml index 9d9352c1..9da19e37 100644 --- a/gradle/deps.versions.toml +++ b/gradle/deps.versions.toml @@ -16,6 +16,7 @@ kotestVer = "6.1.1" jimfsVer = "1.3.1" picocliVer = "4.7.6" +jfrConverterVer = "4.3" coverJetVer = "0.1.4" deltaCoverageVer = "3.7.0" gradleProbeVer = "0.0.2" @@ -31,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" } diff --git a/test-impact-gradle/build.gradle.kts b/test-impact-gradle/build.gradle.kts index 925dc51a..acef410e 100644 --- a/test-impact-gradle/build.gradle.kts +++ b/test-impact-gradle/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { implementation(deps.jackson) implementation(deps.jacksonKotlin) + // async-profiler JFR-to-flamegraph converter + implementation(deps.jfrConverter) + // 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 index 56ce82cf..f1f6bf4e 100644 --- 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 @@ -119,4 +119,36 @@ class TestMappingFunctionalTest { reportDir.resolve("test-mapping.html").exists() shouldBe true reportDir.resolve("flamegraph.html").exists() shouldBe true } + + @Test + fun `flamegraph should be created when enabled`() { + // GIVEN + buildFile.file.appendText( + """ + testImpact { + enabled = true + includePackages.add("com.java.test") + reports { + json.set(false) + flamegraph.set(true) + } + } + """.trimIndent() + ) + + // WHEN + gradleRunner.runTask("test", "analyzeTestMapping") + .apply { + println(output) + } + + // THEN + val reportDir = rootProjectDir.resolve("build/reports/test-impact") + val flamegraphFile = reportDir.resolve("flamegraph.html") + flamegraphFile.exists() shouldBe true + + val content = flamegraphFile.readText() + content shouldContain "canvas" + content shouldContain "async-profiler" + } } diff --git a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt similarity index 76% rename from test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt rename to test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt index 135b3f43..64b96530 100644 --- a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/config/TestImpactConfiguration.kt +++ b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/TestImpactConfiguration.kt @@ -72,3 +72,28 @@ open class TestImpactConfiguration @Inject constructor( 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 index 9c5b0bb2..2791c073 100644 --- 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 @@ -33,7 +33,6 @@ open class TestImpactPlugin : Plugin { ) task.includePackages.set(config.includePackages) task.excludePackages.set(config.excludePackages) - task.jsonEnabled.set(config.reports.json) task.htmlEnabled.set(config.reports.html) task.flamegraphEnabled.set(config.reports.flamegraph) } 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 index 03b73231..30aec9a6 100644 --- 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 @@ -283,7 +283,7 @@ data class AnalyzerConfig( /** * Complete test mapping report. */ -data class TestMappingReport( +internal data class TestMappingReport( val version: Int, val generatedAt: String, val summary: ReportSummary, @@ -326,14 +326,14 @@ data class TestMappingReport( ) } -data class ReportSummary( +internal data class ReportSummary( val totalTests: Int, val totalMethods: Int, val totalSamples: Int, val maxCallDepth: Int ) -data class MethodMapping( +internal data class MethodMapping( val signature: String, val visibility: String, val lineNumber: 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/ConsoleTestMappingReporter.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt deleted file mode 100644 index 09e9b56c..00000000 --- a/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/sampling/testmapping/report/ConsoleTestMappingReporter.kt +++ /dev/null @@ -1,110 +0,0 @@ -package io.github.gwkit.testimpact.gradle.sampling.testmapping.report - -import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.HotMethod -import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.MethodMapping -import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.ReportSummary -import io.github.gwkit.testimpact.gradle.sampling.testmapping.analysis.TestMappingReport - -/** - * Renders test mapping report to console with human-readable formatting. - */ -internal object ConsoleTestMappingReporter { - - private const val LINE_WIDTH = 80 - private const val TOP_METHODS_COUNT = 10 - private const val VISIBILITY_ABBREVIATION_LENGTH = 3 - - fun render(report: TestMappingReport): String = buildString { - appendLine() - appendHeader("Test Mapping Report") - appendLine() - - appendSummary(report.summary) - appendLine() - - if (report.hotMethods.isNotEmpty()) { - appendHotMethods(report.hotMethods) - appendLine() - } - - appendMethodDetails(report.mappings) - appendLine() - - appendLine("─".repeat(LINE_WIDTH)) - } - - private fun StringBuilder.appendHeader(title: String) { - appendLine("─".repeat(LINE_WIDTH)) - appendLine(" $title") - appendLine("─".repeat(LINE_WIDTH)) - } - - private fun StringBuilder.appendSummary(summary: ReportSummary) { - appendLine(" Summary") - appendLine(" Methods: ${summary.totalMethods}") - appendLine(" Tests: ${summary.totalTests}") - appendLine(" Samples: ${summary.totalSamples}") - } - - private fun StringBuilder.appendHotMethods(hotMethods: List) { - appendLine(" Hot Methods (most sampled)") - hotMethods.take(TOP_METHODS_COUNT).forEach { hot -> - val method = formatMethodName(hot.method) - val stats = "${hot.totalHits} hits, ${hot.testCount} tests" - appendLine(" $method") - appendLine(" $stats") - } - } - - private fun StringBuilder.appendMethodDetails(mappings: Map>) { - appendLine(" Method Coverage Details") - - mappings.entries - .sortedBy { it.key } - .forEach { (className, methods) -> - appendLine() - appendLine(" $className") - - methods.entries - .sortedByDescending { it.value.totalHits } - .forEach { (methodName, mapping) -> - appendMethodMapping(methodName, mapping) - } - } - } - - private fun StringBuilder.appendMethodMapping(methodName: String, mapping: MethodMapping) { - val visibility = mapping.visibility.take(VISIBILITY_ABBREVIATION_LENGTH) - val hitsInfo = "${mapping.totalHits} hits" - val testsCount = "${mapping.tests.size} tests" - - appendLine(" [$visibility] $methodName ($hitsInfo, $testsCount)") - - mapping.tests - .sortedByDescending { it.samples } - .forEach { test -> - val testName = formatTestName(test.id) - val depth = "d:${test.depth}" - val samples = "${test.samples} samples" - appendLine(" ├─ $testName ($depth, $samples)") - } - } - - private fun formatMethodName(fullMethod: String): String { - val parts = fullMethod.split("#") - if (parts.size != 2) return fullMethod - - val className = parts[0].substringAfterLast('.') - val methodName = parts[1] - return "$className#$methodName" - } - - private fun formatTestName(testId: String): String { - val parts = testId.split("#") - if (parts.size != 2) return testId - - val className = parts[0].substringAfterLast('.') - val methodName = parts[1] - return "$className.$methodName" - } -} 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/TestMappingAnalysisTask.kt b/test-impact-gradle/src/main/kotlin/io/github/gwkit/testimpact/gradle/task/TestMappingAnalysisTask.kt index 0070a400..ae99daec 100644 --- 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 @@ -3,7 +3,7 @@ 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.ConsoleTestMappingReporter +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 @@ -15,14 +15,11 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction -import javax.inject.Inject /** * Task that analyzes JFR recordings and generates test-to-code mapping reports. */ -abstract class TestMappingAnalysisTask @Inject constructor( - -): DefaultTask() { +abstract class TestMappingAnalysisTask : DefaultTask() { init { group = "verification" @@ -48,9 +45,6 @@ abstract class TestMappingAnalysisTask @Inject constructor( @get:Optional abstract val excludePackages: ListProperty - @get:Input - abstract val jsonEnabled: Property - @get:Input abstract val htmlEnabled: Property @@ -72,7 +66,7 @@ abstract class TestMappingAnalysisTask @Inject constructor( ) val report: TestMappingReport = JfrTestMappingAnalyzer(config).analyze(jfrFiles.files, testClasses) - writeReports(report) + writeReports(report, testClasses) } private fun loadTestClasses(): Set = testEventsFiles.files @@ -81,22 +75,19 @@ abstract class TestMappingAnalysisTask @Inject constructor( .filter { it.isNotBlank() } .toSet() - private fun writeReports(report: TestMappingReport) { + private fun writeReports(report: TestMappingReport, testClasses: Set) { val outputDir = outputDirectory.get().asFile - val writer = ReportWriter( + val reportConfig = ReportConfig( outputDir = outputDir, - jsonEnabled = jsonEnabled.get(), - htmlEnabled = htmlEnabled.get(), - flamegraphEnabled = flamegraphEnabled.get(), + html = htmlEnabled.get(), + flamegraph = flamegraphEnabled.get(), ) + val writer = ReportWriter(reportConfig) - val generatedFiles = writer.write(report, jfrFiles.files) + val generatedFiles = writer.write(report, jfrFiles.files, testClasses) generatedFiles.forEach { file -> logger.lifecycle("Report: file://{}", file.absolutePath) } - - val consoleReport = ConsoleTestMappingReporter.render(report) - logger.lifecycle(consoleReport) } } From ff40b05ad55c5ead60ca41febf9c78526d13d923 Mon Sep 17 00:00:00 2001 From: Sergii Gnatiuk Date: Sun, 8 Mar 2026 01:51:09 +0200 Subject: [PATCH 15/15] refactoring: analyze task per project --- test-impact-gradle/build.gradle.kts | 3 + .../gradle/TestMappingFunctionalTest.kt | 98 ++----------------- .../testimpact/gradle/TestImpactPlugin.kt | 93 +++++++++++------- .../testimpact/gradle/TestImpactPluginTest.kt | 39 ++++++++ 4 files changed, 110 insertions(+), 123 deletions(-) create mode 100644 test-impact-gradle/src/test/kotlin/io/github/gwkit/testimpact/gradle/TestImpactPluginTest.kt diff --git a/test-impact-gradle/build.gradle.kts b/test-impact-gradle/build.gradle.kts index acef410e..628c3946 100644 --- a/test-impact-gradle/build.gradle.kts +++ b/test-impact-gradle/build.gradle.kts @@ -24,6 +24,9 @@ dependencies { // 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 index f1f6bf4e..2e2ff538 100644 --- 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 @@ -8,7 +8,9 @@ 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 @@ -34,64 +36,10 @@ class TestMappingFunctionalTest { @BeforeEach fun beforeEach() { buildFile.restoreOriginContent() - rootProjectDir.resolve("build/reports/test-impact").deleteRecursively() } @Test - fun `test mapping should create JFR recording and test events files`() { - // GIVEN - buildFile.file.appendText( - """ - testImpact { - enabled = true - includePackages.add("com.java.test") - } - """.trimIndent() - ) - - // WHEN - gradleRunner.runTask("test", "analyzeTestMapping") - .apply { - println(output) - } - - // THEN - // Check JFR file exists - val jfrFiles = rootProjectDir.walkTopDown() - .filter { it.name == "recording.jfr" } - .toList() - jfrFiles.shouldNotBeEmpty() - - // Check test-events file exists and contains test class - val testEventsFiles = rootProjectDir.walkTopDown() - .filter { it.name == "test-events.txt" } - .toList() - testEventsFiles.shouldNotBeEmpty() - testEventsFiles.first().readText() shouldContain "Class1Test" - - // Check JSON report file - val jsonFile = rootProjectDir.resolve("build/reports/test-impact/test-mapping.json") - jsonFile.exists() shouldBe true - - val report: Map = jacksonObjectMapper().readValue(jsonFile) - report["version"] shouldBe 1 - report["generatedAt"] shouldNotBe null - - - val summary = report["summary"] as Map - summary["totalTests"] shouldBe 1 - (summary["totalMethods"] as Int) shouldBeGreaterThan 0 - (summary["totalSamples"] as Int) shouldBeGreaterThan 0 - - val mappings = report["mappings"] as Map - mappings.keys.shouldNotBeEmpty() - - // Verify output contains Class1 (the production code) - mappings.keys.any { it.contains("Class1") } shouldBe true - } - - @Test - fun `all report types should be created when all enabled`() { + fun `flamegraph should be created when enabled`() { // GIVEN buildFile.file.appendText( """ @@ -99,7 +47,6 @@ class TestMappingFunctionalTest { enabled = true includePackages.add("com.java.test") reports { - json.set(true) html.set(true) flamegraph.set(true) } @@ -115,40 +62,15 @@ class TestMappingFunctionalTest { // THEN val reportDir = rootProjectDir.resolve("build/reports/test-impact") - reportDir.resolve("test-mapping.json").exists() shouldBe true - reportDir.resolve("test-mapping.html").exists() shouldBe true - reportDir.resolve("flamegraph.html").exists() shouldBe true - } - - @Test - fun `flamegraph should be created when enabled`() { - // GIVEN - buildFile.file.appendText( - """ - testImpact { - enabled = true - includePackages.add("com.java.test") - reports { - json.set(false) - flamegraph.set(true) - } - } - """.trimIndent() - ) - // WHEN - gradleRunner.runTask("test", "analyzeTestMapping") - .apply { - println(output) - } + // TODO +// reportDir.resolve("test-mapping.html").shouldBeAFile() - // THEN - val reportDir = rootProjectDir.resolve("build/reports/test-impact") val flamegraphFile = reportDir.resolve("flamegraph.html") - flamegraphFile.exists() shouldBe true - - val content = flamegraphFile.readText() - content shouldContain "canvas" - content shouldContain "async-profiler" + flamegraphFile.shouldBeAFile() + assertSoftly(flamegraphFile.readText()) { + shouldContain("canvas") + shouldContain("async-profiler") + } } } 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 index 2791c073..cffc1dae 100644 --- 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 @@ -15,41 +15,50 @@ import java.io.File open class TestImpactPlugin : Plugin { override fun apply(project: Project) { - val config = project.extensions.create( + val config: TestImpactConfiguration = project.extensions.create( EXTENSION_NAME, TestImpactConfiguration::class.java, project.objects, ) - val analyzeTask: TaskProvider = project.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) - } + project.configureAllTestTasks(config) + } + private fun Project.configureAllTestTasks( + config: TestImpactConfiguration, + ) { val generateJfcTask: TaskProvider = project.registerGenerateJfcTask(config) - - project.tasks.withType(Test::class.java).configureEach { testTask -> - testTask.configureTestTask(config, generateJfcTask) - with(analyzeTask.get()) { - val jfrFile: File = testTask.temporaryDir.resolve(JFR_FILENAME) - jfrFiles.from(jfrFile) - testEventsFiles.from(testTask.temporaryDir.resolve(TEST_EVENTS_FILENAME)) - mustRunAfter(testTask) + 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 { @@ -60,24 +69,33 @@ open class TestImpactPlugin : Plugin { } } - private fun Test.configureTestTask( + 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, + ) - val jfrFile = temporaryDir.resolve(JFR_FILENAME) - val testEventsFile = temporaryDir.resolve(TEST_EVENTS_FILENAME) + if (config.enabled.get()) { + addTestListener(TestEventsCollector(additionalTaskOutputs.testEventsFile)) - jvmArgumentProviders.add( - JfrCommandLineProvider( - config.enabled, - generateJfcTask.flatMap { it.jfcFile }.map { it.asFile }, - jfrFile + jvmArgumentProviders.add( + JfrCommandLineProvider( + config.enabled, + generateJfcTask.flatMap { it.jfcFile }.map { it.asFile }, + additionalTaskOutputs.jfrData + ) ) - ) + } - addTestListener(TestEventsCollector(testEventsFile)) + return additionalTaskOutputs } private class JfrCommandLineProvider( @@ -97,6 +115,11 @@ open class TestImpactPlugin : Plugin { } } + private data class TestImpactOutputs( + val testEventsFile: File, + val jfrData: File, + ) + companion object { const val EXTENSION_NAME = "testImpact" const val ANALYZE_TASK_NAME = "analyzeTestMapping" 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", + ) + } + } +}