From e0b1035e323e0977e9bb913ecb94fc71564d138b Mon Sep 17 00:00:00 2001 From: Stefan Knilling Date: Thu, 15 Oct 2026 13:28:02 +0200 Subject: [PATCH 1/4] TS-46354 Support nested chars --- CHANGELOG.md | 1 + .../testwise/jacoco/cache/AnalyzerCache.kt | 10 ++-- .../jacoco/cache/AnalyzerCacheTest.kt | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b55c7984..8ec98c2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ We use [semantic versioning](http://semver.org/): - PATCH version when you make backwards compatible bug fixes. # Next version +- [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains a JAR with a nested JAR. # 39.0.0 - [breaking] _agent_: The deprecated Docker image `cqse/teamscale-jacoco-agent` is no longer published. Use `cqse/teamscale-java-profiler` instead, which has replaced it since version 36.5.0. diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index bb718aaff..39b50b47a 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -11,8 +11,6 @@ import org.jacoco.core.internal.flow.ClassProbesAdapter import org.jacoco.core.internal.instr.InstrSupport import java.io.IOException import java.io.InputStream -import java.nio.file.Files -import java.nio.file.Paths /** * An [AnalyzerCache] instance processes a set of Java class/jar/war/... files and builds a cache for each of the classes. @@ -55,15 +53,19 @@ class AnalyzerCache( /** * Adds caching for jar files to the analyze jar functionality. + * + * Reads the jar from the given stream and not from [location], because nested jars have locations like + * `outer.jar@path/inner.jar`, which do not exist on disk. */ @Throws(IOException::class) override fun analyzeJar(input: InputStream, location: String): Int { - val jarId = CRC64.classId(Files.readAllBytes(Paths.get(location))) + val bytes = input.readBytes() + val jarId = CRC64.classId(bytes) val probesCountForJarId = probesCache.countForJarId(jarId) if (probesCountForJarId != 0) { return probesCountForJarId } - val count = super.analyzeJar(input, location) + val count = super.analyzeJar(bytes.inputStream(), location) probesCache.addJarId(jarId, count) return count } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt new file mode 100644 index 000000000..329d6e123 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt @@ -0,0 +1,49 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.assertj.core.api.Assertions.assertThat +import org.jacoco.core.internal.data.CRC64 +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.mockito.Mockito +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Tests for the [AnalyzerCache] class. */ +class AnalyzerCacheTest { + + /** + * A JAR may contain other JARs (e.g. SVNKit). The analyzer addresses a nested JAR as `outer.jar@path/inner.jar`, + * which is not a file on disk, so the cache must not try to read it from there. + */ + @Test + fun analyzesClassesInNestedJars(@TempDir tempDir: File) { + val classBytes = javaClass.getResourceAsStream("AnalyzerCacheTest.class")!!.use { it.readBytes() } + val innerJar = zip("com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.class" to classBytes) + tempDir.resolve("outer.jar").writeBytes(zip("lib/inner.jar" to innerJar)) + + val logger = Mockito.mock() + val probesCache = ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE) + val count = AnalyzerCache(probesCache, ClasspathWildcardIncludeFilter(null, null), logger) + .analyzeAll(tempDir) + + assertThat(count).isEqualTo(1) + assertThat(probesCache.containsClassId(CRC64.classId(classBytes))).isTrue() + } + + private fun zip(vararg entries: Pair): ByteArray { + val output = ByteArrayOutputStream() + ZipOutputStream(output).use { zip -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return output.toByteArray() + } +} From 7bb9cc58165bc926c525dc6aaa875e73bbc583f2 Mon Sep 17 00:00:00 2001 From: Stefan Knilling Date: Thu, 15 Oct 2026 13:40:22 +0200 Subject: [PATCH 2/4] TS-46354 Avoid increased memory usage --- .../testwise/jacoco/cache/AnalyzerCache.kt | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index 39b50b47a..a25c7bddd 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -9,8 +9,10 @@ import org.jacoco.core.internal.analysis.StringPool import org.jacoco.core.internal.data.CRC64 import org.jacoco.core.internal.flow.ClassProbesAdapter import org.jacoco.core.internal.instr.InstrSupport +import java.io.File import java.io.IOException import java.io.InputStream +import java.nio.file.Files /** * An [AnalyzerCache] instance processes a set of Java class/jar/war/... files and builds a cache for each of the classes. @@ -54,18 +56,27 @@ class AnalyzerCache( /** * Adds caching for jar files to the analyze jar functionality. * - * Reads the jar from the given stream and not from [location], because nested jars have locations like - * `outer.jar@path/inner.jar`, which do not exist on disk. + * The jar ID of a jar on disk is computed from the file, so the jar is only held in memory while the ID is + * computed. A nested jar has a location like `outer.jar@path/inner.jar`, which does not exist on disk. Its bytes + * are only available from [input], so they are buffered to compute the ID and to analyze the jar. */ @Throws(IOException::class) override fun analyzeJar(input: InputStream, location: String): Int { + val file = File(location) + if (file.isFile) { + return analyzeJarWithCache(CRC64.classId(Files.readAllBytes(file.toPath())), input, location) + } val bytes = input.readBytes() - val jarId = CRC64.classId(bytes) + return analyzeJarWithCache(CRC64.classId(bytes), bytes.inputStream(), location) + } + + /** Analyzes the given jar unless a jar with the same [jarId] has been analyzed before. */ + private fun analyzeJarWithCache(jarId: Long, input: InputStream, location: String): Int { val probesCountForJarId = probesCache.countForJarId(jarId) if (probesCountForJarId != 0) { return probesCountForJarId } - val count = super.analyzeJar(bytes.inputStream(), location) + val count = super.analyzeJar(input, location) probesCache.addJarId(jarId, count) return count } From 3706f2272b40cf1b32c61c8590d1bf5fbfdac024 Mon Sep 17 00:00:00 2001 From: Stefan Knilling Date: Thu, 15 Oct 2026 15:06:50 +0200 Subject: [PATCH 3/4] TS-46354 Report a specific error for unreadable nested jars --- .../testwise/jacoco/cache/AnalyzerCache.kt | 6 +++++- .../jacoco/cache/AnalyzerCacheTest.kt | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index a25c7bddd..94e73b53f 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -66,7 +66,11 @@ class AnalyzerCache( if (file.isFile) { return analyzeJarWithCache(CRC64.classId(Files.readAllBytes(file.toPath())), input, location) } - val bytes = input.readBytes() + val bytes = try { + input.readBytes() + } catch (e: IOException) { + throw analyzerError(location, e) + } return analyzeJarWithCache(CRC64.classId(bytes), bytes.inputStream(), location) } diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt index 329d6e123..ddb94e6f2 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt @@ -4,12 +4,14 @@ import com.teamscale.report.EDuplicateClassFileBehavior import com.teamscale.report.util.ClasspathWildcardIncludeFilter import com.teamscale.report.util.ILogger import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy import org.jacoco.core.internal.data.CRC64 import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import org.mockito.Mockito import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -35,6 +37,25 @@ class AnalyzerCacheTest { assertThat(probesCache.containsClassId(CRC64.classId(classBytes))).isTrue() } + /** A nested JAR that cannot be read must be reported with its location, so that users can find it. */ + @Test + fun reportsLocationOfUnreadableNestedJar(@TempDir tempDir: File) { + val classBytes = javaClass.getResourceAsStream("AnalyzerCacheTest.class")!!.use { it.readBytes() } + val innerJar = zip("com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.class" to classBytes) + val outerJar = zip("lib/inner.jar" to innerJar) + // Cuts the outer JAR in the middle of the nested JAR's data + tempDir.resolve("outer.jar").writeBytes(outerJar.copyOf(outerJar.size / 2)) + + val logger = Mockito.mock() + val analyzer = AnalyzerCache( + ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE), ClasspathWildcardIncludeFilter(null, null), logger + ) + + assertThatThrownBy { analyzer.analyzeAll(tempDir) } + .isInstanceOf(IOException::class.java) + .hasMessageContaining("outer.jar@lib/inner.jar") + } + private fun zip(vararg entries: Pair): ByteArray { val output = ByteArrayOutputStream() ZipOutputStream(output).use { zip -> From a71768ddd88a7d365810a1271ea4d0398e71b528 Mon Sep 17 00:00:00 2001 From: Stefan Knilling Date: Fri, 16 Oct 2026 09:28:44 +0200 Subject: [PATCH 4/4] TS-48002 Make explicit that the fix works for any nested archives --- CHANGELOG.md | 2 +- .../testwise/jacoco/cache/AnalyzerCache.kt | 19 +++++++-- .../jacoco/cache/AnalyzerCacheTest.kt | 42 +++++++++---------- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec98c2c0..180ee741b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ We use [semantic versioning](http://semver.org/): - PATCH version when you make backwards compatible bug fixes. # Next version -- [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains a JAR with a nested JAR. +- [fix] _teamscale-gradle-plugin_, _teamscale-maven-plugin_: Testwise coverage reports no longer drop a class directory that contains an archive (JAR, WAR, EAR, ZIP) that contains a JAR. # 39.0.0 - [breaking] _agent_: The deprecated Docker image `cqse/teamscale-jacoco-agent` is no longer published. Use `cqse/teamscale-java-profiler` instead, which has replaced it since version 36.5.0. diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index 94e73b53f..4ea7496dd 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -57,15 +57,26 @@ class AnalyzerCache( * Adds caching for jar files to the analyze jar functionality. * * The jar ID of a jar on disk is computed from the file, so the jar is only held in memory while the ID is - * computed. A nested jar has a location like `outer.jar@path/inner.jar`, which does not exist on disk. Its bytes - * are only available from [input], so they are buffered to compute the ID and to analyze the jar. + * computed. The jar itself is then streamed from [input] while it is analyzed. */ @Throws(IOException::class) override fun analyzeJar(input: InputStream, location: String): Int { val file = File(location) - if (file.isFile) { - return analyzeJarWithCache(CRC64.classId(Files.readAllBytes(file.toPath())), input, location) + // Checks the file system instead of looking for '@' in the location, because real paths may contain '@' as + // well (e.g. the Jenkins workspace `workspace@2`). Such jars must still be streamed instead of buffered. + if (!file.isFile) { + return analyzeJarInsideArchive(input, location) } + return analyzeJarWithCache(CRC64.classId(Files.readAllBytes(file.toPath())), input, location) + } + + /** + * Analyzes a jar that is an entry of another archive (JAR, WAR, EAR, ZIP, ...). Such a jar has a location like + * `app.war@WEB-INF/lib/inner.jar`, which is not a file on disk. Its bytes are only available from [input], so + * they are buffered to compute the jar ID and to analyze the jar. + */ + @Throws(IOException::class) + private fun analyzeJarInsideArchive(input: InputStream, location: String): Int { val bytes = try { input.readBytes() } catch (e: IOException) { diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt index ddb94e6f2..556794a6c 100644 --- a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt @@ -8,6 +8,8 @@ import org.assertj.core.api.Assertions.assertThatThrownBy import org.jacoco.core.internal.data.CRC64 import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import java.io.ByteArrayOutputStream import java.io.File @@ -18,39 +20,37 @@ import java.util.zip.ZipOutputStream /** Tests for the [AnalyzerCache] class. */ class AnalyzerCacheTest { + @TempDir + private lateinit var tempDir: File + + private val classBytes = javaClass.getResourceAsStream("AnalyzerCacheTest.class")!!.use { it.readBytes() } + private val innerJar = zip("com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.class" to classBytes) + + private val logger = Mockito.mock() + private val probesCache = ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE) + private val analyzer = AnalyzerCache(probesCache, ClasspathWildcardIncludeFilter(null, null), logger) + /** - * A JAR may contain other JARs (e.g. SVNKit). The analyzer addresses a nested JAR as `outer.jar@path/inner.jar`, - * which is not a file on disk, so the cache must not try to read it from there. + * An archive may contain JARs, e.g. a WAR contains its libraries and some JARs contain other JARs (e.g. SVNKit). + * The analyzer addresses such a JAR as `app.war@WEB-INF/lib/inner.jar`, which is not a file on disk, so the cache + * must not try to read it from there. */ - @Test - fun analyzesClassesInNestedJars(@TempDir tempDir: File) { - val classBytes = javaClass.getResourceAsStream("AnalyzerCacheTest.class")!!.use { it.readBytes() } - val innerJar = zip("com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.class" to classBytes) - tempDir.resolve("outer.jar").writeBytes(zip("lib/inner.jar" to innerJar)) - - val logger = Mockito.mock() - val probesCache = ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE) - val count = AnalyzerCache(probesCache, ClasspathWildcardIncludeFilter(null, null), logger) - .analyzeAll(tempDir) + @ParameterizedTest + @CsvSource("outer.jar, lib/inner.jar", "app.war, WEB-INF/lib/inner.jar") + fun analyzesClassesInJarsInsideArchives(archiveName: String, jarPath: String) { + tempDir.resolve(archiveName).writeBytes(zip(jarPath to innerJar)) - assertThat(count).isEqualTo(1) + assertThat(analyzer.analyzeAll(tempDir)).isEqualTo(1) assertThat(probesCache.containsClassId(CRC64.classId(classBytes))).isTrue() } /** A nested JAR that cannot be read must be reported with its location, so that users can find it. */ @Test - fun reportsLocationOfUnreadableNestedJar(@TempDir tempDir: File) { - val classBytes = javaClass.getResourceAsStream("AnalyzerCacheTest.class")!!.use { it.readBytes() } - val innerJar = zip("com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.class" to classBytes) + fun reportsLocationOfUnreadableNestedJar() { val outerJar = zip("lib/inner.jar" to innerJar) // Cuts the outer JAR in the middle of the nested JAR's data tempDir.resolve("outer.jar").writeBytes(outerJar.copyOf(outerJar.size / 2)) - val logger = Mockito.mock() - val analyzer = AnalyzerCache( - ProbesCache(logger, EDuplicateClassFileBehavior.IGNORE), ClasspathWildcardIncludeFilter(null, null), logger - ) - assertThatThrownBy { analyzer.analyzeAll(tempDir) } .isInstanceOf(IOException::class.java) .hasMessageContaining("outer.jar@lib/inner.jar")