diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b55c7984..180ee741b 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 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 bb718aaff..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 @@ -9,10 +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 -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,10 +55,38 @@ 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. The jar itself is then streamed from [input] while it is analyzed. */ @Throws(IOException::class) override fun analyzeJar(input: InputStream, location: String): Int { - val jarId = CRC64.classId(Files.readAllBytes(Paths.get(location))) + val file = File(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) { + throw analyzerError(location, e) + } + 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 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..556794a6c --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCacheTest.kt @@ -0,0 +1,70 @@ +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.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 +import java.io.IOException +import java.util.zip.ZipEntry +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) + + /** + * 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. + */ + @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(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() { + 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)) + + 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 -> + entries.forEach { (name, content) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(content) + zip.closeEntry() + } + } + return output.toByteArray() + } +}