From b529a76adeef545919f85f3ea067c7409fb5a7b6 Mon Sep 17 00:00:00 2001 From: Christian Inhetveen Date: Thu, 28 May 2026 12:42:48 +0200 Subject: [PATCH 1/8] TS-38628 Migrate missing files in report generator --- .../jacoco/agent/options/AgentOptions.kt | 4 +- .../agent/options/AgentOptionsParser.kt | 8 +- .../agent/upload/teamscale/TeamscaleConfig.kt | 4 +- .../agent/options/FilePatternResolverTest.kt | 9 +- .../jacoco/agent/options/ClasspathUtils.java | 46 ---- .../agent/options/FilePatternResolver.java | 203 ------------------ .../jacoco.agent.options/ClasspathUtils.kt | 47 ++++ .../FilePatternResolver.kt | 183 ++++++++++++++++ .../com/teamscale/client/FileSystemUtils.kt | 7 - 9 files changed, 243 insertions(+), 268 deletions(-) delete mode 100644 report-generator/src/main/java/com/teamscale/jacoco/agent/options/ClasspathUtils.java delete mode 100644 report-generator/src/main/java/com/teamscale/jacoco/agent/options/FilePatternResolver.java create mode 100644 report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt create mode 100644 report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt index 6480e195a..b30735d89 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt @@ -84,7 +84,7 @@ open class AgentOptions(private val logger: ILogger) { * should be dumped to a temporary directory which should be used as the class-dir. */ @JvmField - var classDirectoriesOrZips = mutableListOf() + var classDirectoriesOrZips = listOf() /** * The logging configuration file. @@ -231,7 +231,7 @@ open class AgentOptions(private val logger: ILogger) { * we produce a string with obfuscation: * "config-file=jacocoagent.properties,teamscale-access-token=************mNHn" */ - val obfuscatedOptionsString: String? + val obfuscatedOptionsString: String get() = obfuscateAccessToken(originalOptionsString) /** diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt index 7538d7014..b7d3e0e62 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptionsParser.kt @@ -236,7 +236,7 @@ class AgentOptionsParser @VisibleForTesting internal constructor( return true } CONFIG_FILE_OPTION -> { - readConfigFromFile(options, parsePath(filePatternResolver, key, value)!!.toFile()) + readConfigFromFile(options, parsePath(filePatternResolver, key, value).toFile()) return true } LOGGING_CONFIG_OPTION -> { @@ -252,7 +252,7 @@ class AgentOptionsParser @VisibleForTesting internal constructor( return true } "out" -> { - options.setParentOutputDirectory(parsePath(filePatternResolver, key, value)!!) + options.setParentOutputDirectory(parsePath(filePatternResolver, key, value)) return true } "upload-metadata" -> { @@ -499,8 +499,8 @@ class AgentOptionsParser @VisibleForTesting internal constructor( fun parsePath( filePatternResolver: FilePatternResolver, optionName: String?, - pattern: String? - ): Path? { + pattern: String + ): Path { try { return filePatternResolver.parsePath(optionName, pattern) } catch (e: IOException) { diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt index 76f031d14..81e0e0f8c 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleConfig.kt @@ -53,7 +53,7 @@ class TeamscaleConfig( return true } TEAMSCALE_COMMIT_MANIFEST_JAR_OPTION -> { - val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) ?: return false + val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) teamscaleServer.commit = getCommitFromManifest(path.toFile()) return true } @@ -70,7 +70,7 @@ class TeamscaleConfig( return true } TEAMSCALE_REVISION_MANIFEST_JAR_OPTION -> { - val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) ?: return false + val path = AgentOptionsParser.parsePath(filePatternResolver, key, value) teamscaleServer.revision = getRevisionFromManifest(path.toFile()) return true } diff --git a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolverTest.kt b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolverTest.kt index a5e063590..c51eda199 100644 --- a/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolverTest.kt +++ b/agent/src/test/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolverTest.kt @@ -13,7 +13,7 @@ import java.io.IOException /** Tests the [AgentOptions]. */ class FilePatternResolverTest { @TempDir - protected var testFolder: File? = null + lateinit var testFolder: File @BeforeEach @Throws(IOException::class) @@ -28,7 +28,7 @@ class FilePatternResolverTest { @Test @Throws(IOException::class) fun testPathResolutionForAbsolutePath() { - assertInputInWorkingDirectoryMatches(".", testFolder!!.absolutePath, "") + assertInputInWorkingDirectoryMatches(".", testFolder.absolutePath, "") } /** Tests path resolution with relative paths. */ @@ -54,14 +54,15 @@ class FilePatternResolverTest { @Throws(IOException::class) fun testPathResolutionWithPatternsAndAbsolutePaths() { assertInputInWorkingDirectoryMatches( - "plugins", testFolder!!.getAbsolutePath() + "/plugins/file_*.jar", + "plugins", testFolder.absolutePath + "/plugins/file_*.jar", "plugins/file_with_manifest2.jar" ) } @Throws(IOException::class) private fun assertInputInWorkingDirectoryMatches( - workingDir: String, input: String?, + workingDir: String, + input: String, expected: String ) { val workingDirectory = File(testFolder, workingDir) diff --git a/report-generator/src/main/java/com/teamscale/jacoco/agent/options/ClasspathUtils.java b/report-generator/src/main/java/com/teamscale/jacoco/agent/options/ClasspathUtils.java deleted file mode 100644 index 4d5862527..000000000 --- a/report-generator/src/main/java/com/teamscale/jacoco/agent/options/ClasspathUtils.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.FileSystemUtils; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** Handles parsing a .txt file with classpath pattern separated by newlines. */ -public class ClasspathUtils { - - /** Replaces all txt files in the given list with the file names written in the txt file separated by new lines. */ - public static List resolveClasspathTextFiles(String key, FilePatternResolver filePatternResolver, - List patterns) throws IOException { - List resolvedPaths = new ArrayList<>(); - for (String pattern : patterns) { - resolvedPaths.addAll(filePatternResolver.resolveToMultipleFiles(key, pattern)); - } - Map> filesByType = resolvedPaths.stream() - .collect(Collectors.partitioningBy(file -> file.getName().endsWith(".txt"))); - List classDirOrJarFiles = new ArrayList<>(filesByType.get(false)); - for (File txtFile : filesByType.get(true)) { - classDirOrJarFiles.addAll(resolveClassPathEntries(key, filePatternResolver, txtFile)); - } - return classDirOrJarFiles; - } - - private static List resolveClassPathEntries(String key, FilePatternResolver filePatternResolver, - File txtFile) throws IOException { - List filePaths; - try { - filePaths = FileSystemUtils.readLinesUTF8(txtFile); - } catch (IOException e) { - throw new IOException("Failed read class path entries from the provided " + txtFile + - " in the `" + key + "` option.", e); - } - List resolvedFiles = new ArrayList<>(); - for (String filePath : filePaths) { - resolvedFiles.addAll(filePatternResolver.resolveToMultipleFiles(key, filePath)); - } - return resolvedFiles; - } -} diff --git a/report-generator/src/main/java/com/teamscale/jacoco/agent/options/FilePatternResolver.java b/report-generator/src/main/java/com/teamscale/jacoco/agent/options/FilePatternResolver.java deleted file mode 100644 index 4e58f92b3..000000000 --- a/report-generator/src/main/java/com/teamscale/jacoco/agent/options/FilePatternResolver.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.teamscale.jacoco.agent.options; - -import com.teamscale.client.AntPatternUtils; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.report.util.ILogger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.List; -import java.util.function.Predicate; -import java.util.regex.Pattern; - -import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toList; - -/** Helper class to support resolving file paths which may contain Ant patterns. */ -public class FilePatternResolver { - - /** Stand-in for the question mark operator. */ - private static final String QUESTION_REPLACEMENT = "!@"; - - /** Stand-in for the asterisk operator. */ - private static final String ASTERISK_REPLACEMENT = "#@"; - - private final ILogger logger; - - public FilePatternResolver(ILogger logger) { - this.logger = logger; - } - - /** - * Interprets the given pattern as an Ant pattern and resolves it to one existing {@link Path}. If the given path is - * relative, it is resolved relative to the current working directory. If more than one file matches the pattern, - * one of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be - * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each - * time. - */ - public Path parsePath(String optionName, String pattern) throws IOException { - return parsePath(optionName, pattern, new File(".")); - } - - /** - * Interprets the given pattern as an Ant pattern and resolves it to one or multiple existing {@link File}s. If the - * given path is relative, it is resolved relative to the current working directory. - */ - public List resolveToMultipleFiles(String optionName, String pattern) throws IOException { - return resolveToMultipleFiles(optionName, pattern, new File(".")); - } - - /** - * Interprets the given pattern as an Ant pattern and resolves it to one or multiple existing {@link File}s. If the - * given path is relative, it is resolved relative to the current working directory. - *

- * Visible for testing only. - */ - /* package */ List resolveToMultipleFiles(String optionName, String pattern, - File workingDirectory) throws IOException { - if (isPathWithPattern(pattern)) { - return parseFileFromPattern(optionName, pattern, workingDirectory).getAllMatchingPaths().stream() - .map(Path::toFile).collect(toList()); - } - try { - return Collections.singletonList(workingDirectory.toPath().resolve(Paths.get(pattern)).toFile()); - } catch (InvalidPathException e) { - throw new IOException("Invalid path given for option " + optionName + ": " + pattern, e); - } - } - - /** - * Interprets the given pattern as an Ant pattern and resolves it to one existing {@link Path}. If the given path is - * relative, it is resolved relative to the given working directory. If more than one file matches the pattern, one - * of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be - * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each - * time. - */ - /* package */ Path parsePath(String optionName, String pattern, - File workingDirectory) throws IOException { - if (isPathWithPattern(pattern)) { - return parseFileFromPattern(optionName, pattern, workingDirectory).getSinglePath(); - } - try { - return workingDirectory.toPath().resolve(Paths.get(pattern)); - } catch (InvalidPathException e) { - throw new IOException("Invalid path given for option " + optionName + ": " + pattern, e); - } - } - - /** Parses the pattern as a Ant pattern to one or multiple files or directories. */ - private FilePatternResolverRun parseFileFromPattern(String optionName, - String pattern, - File workingDirectory) throws IOException { - return new FilePatternResolverRun(logger, optionName, pattern, workingDirectory).resolve(); - } - - /** Returns whether the given path contains Ant pattern characters (?,*). */ - private static boolean isPathWithPattern(String path) { - return path.contains("?") || path.contains("*"); - } - - /** - * Returns whether the given path contains artificial pattern characters ({@link #QUESTION_REPLACEMENT}, - * {@link #ASTERISK_REPLACEMENT}). - */ - private static boolean isPathWithArtificialPattern(String path) { - return path.contains(QUESTION_REPLACEMENT) || path.contains(ASTERISK_REPLACEMENT); - } - - private static class FilePatternResolverRun { - private final File workingDirectory; - private final String optionName; - private final String pattern; - private String suffixPattern; - private Path basePath; - private List matchingPaths; - private final ILogger logger; - - private FilePatternResolverRun(ILogger logger, String optionName, String pattern, File workingDirectory) { - this.logger = logger; - this.optionName = optionName; - this.pattern = pattern; - this.workingDirectory = workingDirectory.getAbsoluteFile(); - splitIntoBasePathAndPattern(pattern); - } - - /** - * Resolves the pattern. The results can be retrieved via {@link #getSinglePath()} or - * {@link #getAllMatchingPaths()}. - */ - private FilePatternResolverRun resolve() throws IOException { - Pattern pathRegex = AntPatternUtils.convertPattern(suffixPattern, false); - Predicate filter = path -> pathRegex - .matcher(FileSystemUtils.normalizeSeparators(basePath.relativize(path).toString())).matches(); - - try { - matchingPaths = Files.walk(basePath).filter(filter).sorted().collect(toList()); - } catch (IOException e) { - throw new IOException( - "Could not recursively list files in directory " + basePath + " in order to resolve pattern " + suffixPattern + " given for option " + optionName, - e); - } - return this; - } - - /** - * Splits the path into a base dir, i.e. the directory-prefix of the path that does not contain any ? or * - * placeholders, and a pattern suffix. We need to replace the pattern characters with stand-ins, because ? and * - * are not allowed as path characters on windows. - */ - private void splitIntoBasePathAndPattern(String value) { - String pathWithArtificialPattern = value.replace("?", QUESTION_REPLACEMENT) - .replace("*", ASTERISK_REPLACEMENT); - Path pathWithPattern = Paths.get(pathWithArtificialPattern); - Path baseDir = pathWithPattern; - while (isPathWithArtificialPattern(baseDir.toString())) { - baseDir = baseDir.getParent(); - if (baseDir == null) { - suffixPattern = value; - basePath = workingDirectory.toPath().resolve("").normalize().toAbsolutePath(); - return; - } - } - - suffixPattern = baseDir.relativize(pathWithPattern).toString().replace(QUESTION_REPLACEMENT, "?") - .replace(ASTERISK_REPLACEMENT, "*"); - basePath = workingDirectory.toPath().resolve(baseDir).normalize().toAbsolutePath(); - } - - /** Returns the result of a resolution as a single Path and warns when multiple paths match. */ - private Path getSinglePath() throws IOException { - if (this.matchingPaths.isEmpty()) { - throw new IOException( - "Invalid path given for option " + optionName + ": " + this.pattern + ". The pattern " + this.suffixPattern + - " did not match any files in " + this.basePath.toAbsolutePath()); - } else if (this.matchingPaths.size() > 1) { - logger.warn( - "Multiple files match the pattern " + this.suffixPattern + " in " + this.basePath - .toString() + " for option " + optionName + "! " + - "The first one is used, but consider to adjust the " + - "pattern to match only one file. Candidates are: " + this.matchingPaths.stream() - .map(this.basePath::relativize).map(Path::toString).collect(joining(", "))); - } - Path path = this.matchingPaths.get(0).normalize(); - logger.info("Using file " + path + " for option " + optionName); - return path; - } - - /** Returns all matched paths after the resolution. */ - private List getAllMatchingPaths() { - if (this.matchingPaths.isEmpty()) { - logger.warn( - "The pattern " + this.suffixPattern + " in " + this.basePath - .toString() + " for option " + optionName + " did not match any file!"); - } - logger.info("Resolved " + pattern + " to " + this.matchingPaths.size() + " for option " + optionName); - return this.matchingPaths; - } - } -} diff --git a/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt b/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt new file mode 100644 index 000000000..55303debf --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt @@ -0,0 +1,47 @@ +package com.teamscale.jacoco.agent.options + +import java.io.File +import java.io.IOException + +/** Handles parsing a .txt file with classpath pattern separated by newlines. */ +object ClasspathUtils { + + /** Replaces all txt files in the given list with the file names written in the txt file separated by new lines. */ + @Throws(IOException::class) + fun resolveClasspathTextFiles( + key: String, + filePatternResolver: FilePatternResolver, + patterns: List + ): List { + val (txtFiles, classDirOrJarFiles) = patterns.flatMap { pattern -> + filePatternResolver.resolveToMultipleFiles(key, pattern) + }.partition { file -> + file.name.endsWith(".txt") + } + + val resolvedFromTxt = txtFiles.flatMap { txtFile -> + resolveClassPathEntries(key, filePatternResolver, txtFile) + } + + return classDirOrJarFiles + resolvedFromTxt + } + + @Throws(IOException::class) + private fun resolveClassPathEntries( + key: String, + filePatternResolver: FilePatternResolver, + txtFile: File + ): List { + val filePaths = try { + txtFile.readLines() + } catch (e: IOException) { + throw IOException( + "Failed to read class path entries from the provided $txtFile in the `$key` option.", e + ) + } + + return filePaths.flatMap { filePath -> + filePatternResolver.resolveToMultipleFiles(key, filePath) + } + } +} \ No newline at end of file diff --git a/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt b/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt new file mode 100644 index 000000000..bc366dd07 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt @@ -0,0 +1,183 @@ +package com.teamscale.jacoco.agent.options + +import com.teamscale.client.AntPatternUtils +import com.teamscale.client.FileSystemUtils.normalizeSeparators +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path +import java.nio.file.Paths +import java.util.function.Predicate +import java.util.stream.Collectors +import kotlin.io.path.walk + +/** Helper class to support resolving file paths which may contain Ant patterns. */ +class FilePatternResolver(private val logger: ILogger) { + /** + * Interprets the given pattern as an Ant pattern and resolves it to one or multiple existing [File]s. If the + * given path is relative, it is resolved relative to the current working directory. + * + * + * Visible for testing only. + */ + @Throws(IOException::class) + fun resolveToMultipleFiles( + optionName: String?, pattern: String, + workingDirectory: File = File(".") + ): List { + if (isPathWithPattern(pattern)) { + return parseFileFromPattern(optionName, pattern, workingDirectory).allMatchingPaths + .map { it.toFile() } + } + try { + return listOf(workingDirectory.toPath().resolve(Paths.get(pattern)).toFile()) + } catch (e: InvalidPathException) { + throw IOException("Invalid path given for option $optionName: $pattern", e) + } + } + + /** + * Interprets the given pattern as an Ant pattern and resolves it to one existing [Path]. If the given path is + * relative, it is resolved relative to the given working directory. If more than one file matches the pattern, one + * of the matching files is used without any guarantees as to which. The selection is, however, guaranteed to be + * deterministic, i.e. if you run the pattern twice and get the same set of files, the same file will be picked each + * time. + */ + @Throws(IOException::class) + fun parsePath( + optionName: String?, + pattern: String, + workingDirectory: File = File(".") + ): Path { + if (isPathWithPattern(pattern)) { + return parseFileFromPattern(optionName, pattern, workingDirectory).singlePath + } + try { + return workingDirectory.toPath().resolve(Paths.get(pattern)) + } catch (e: InvalidPathException) { + throw IOException("Invalid path given for option $optionName: $pattern", e) + } + } + + /** Parses the pattern as an Ant pattern to one or multiple files or directories. */ + @Throws(IOException::class) + private fun parseFileFromPattern( + optionName: String?, + pattern: String, + workingDirectory: File + ): FilePatternResolverRun { + return FilePatternResolverRun(logger, optionName, pattern, workingDirectory).resolve() + } + + private class FilePatternResolverRun( + private val logger: ILogger, + private val optionName: String?, + private val pattern: String, + workingDirectory: File + ) { + private val workingDirectory = workingDirectory.getAbsoluteFile() + private lateinit var suffixPattern: String + private lateinit var basePath: Path + private var matchingPaths = listOf() + + init { + splitIntoBasePathAndPattern(pattern) + } + + /** + * Splits the path into a base dir, i.e. the directory-prefix of the path that does not contain any ? or * + * placeholders, and a pattern suffix. We need to replace the pattern characters with stand-ins, because ? and * + * are not allowed as path characters on windows. + */ + fun splitIntoBasePathAndPattern(value: String) { + val pathWithArtificialPattern = value.replace("?", QUESTION_REPLACEMENT) + .replace("*", ASTERISK_REPLACEMENT) + val pathWithPattern = Paths.get(pathWithArtificialPattern) + var baseDir = pathWithPattern + while (isPathWithArtificialPattern(baseDir.toString())) { + baseDir = baseDir.parent + if (baseDir == null) { + suffixPattern = value + basePath = workingDirectory.toPath().resolve("").normalize().toAbsolutePath() + return + } + } + + suffixPattern = baseDir.relativize(pathWithPattern).toString() + .replace(QUESTION_REPLACEMENT, "?") + .replace(ASTERISK_REPLACEMENT, "*") + basePath = workingDirectory.toPath().resolve(baseDir).normalize().toAbsolutePath() + } + + val singlePath: Path + /** Returns the result of a resolution as a single Path and warns when multiple paths match. */ + get() { + if (matchingPaths.isEmpty()) { + throw IOException( + "Invalid path given for option $optionName: ${pattern}. The pattern $suffixPattern did not match any files in ${basePath.toAbsolutePath()}" + ) + } else if (matchingPaths.size > 1) { + logger.warn( + "Multiple files match the pattern $suffixPattern in $basePath for option $optionName! The first one is used, but consider to adjust the pattern to match only one file. Candidates are: " + matchingPaths.joinToString { + basePath.relativize(it).toString() + } + ) + } + val path = matchingPaths.first().normalize() + logger.info("Using file $path for option $optionName") + return path + } + + val allMatchingPaths: List + /** Returns all matched paths after the resolution. */ + get() { + if (matchingPaths.isEmpty()) { + logger.warn( + "The pattern $suffixPattern in $basePath for option $optionName did not match any file!" + ) + } + logger.info("Resolved $pattern to ${matchingPaths.size} for option $optionName") + return matchingPaths + } + + /** + * Resolves the pattern. The results can be retrieved via [singlePath] or [allMatchingPaths]. + */ + fun resolve(): FilePatternResolverRun { + val pathRegex = AntPatternUtils.convertPattern(suffixPattern, false) + + try { + matchingPaths = basePath.walk().filter { + pathRegex.matcher(normalizeSeparators(basePath.relativize(it).toString())).matches() + }.sorted().toList() + } catch (e: IOException) { + throw IOException( + "Could not recursively list files in directory $basePath in order to resolve pattern $suffixPattern given for option $optionName", + e + ) + } + return this + } + } + + companion object { + /** Stand-in for the question mark operator. */ + private const val QUESTION_REPLACEMENT = "!@" + + /** Stand-in for the asterisk operator. */ + private const val ASTERISK_REPLACEMENT = "#@" + + /** Returns whether the given path contains Ant pattern characters (?,*). */ + private fun isPathWithPattern(path: String) = + path.contains("?") || path.contains("*") + + /** + * Returns whether the given path contains artificial pattern characters ([QUESTION_REPLACEMENT], + * [ASTERISK_REPLACEMENT]). + */ + private fun isPathWithArtificialPattern(path: String) = + path.contains(QUESTION_REPLACEMENT) || path.contains(ASTERISK_REPLACEMENT) + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt index fe484f970..9350c2c75 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt @@ -206,13 +206,6 @@ object FileSystemUtils { } } - /** - * Read file content into a list of lines (strings) using UTF-8 encoding. - */ - @JvmStatic - @Throws(IOException::class) - fun readLinesUTF8(file: File) = file.readLines() - /** * Copy all files specified by a file filter from one directory to another. This * automatically creates all necessary directories. From 6da6dd57d09052fcde5dcfb98cc7a0c68a1d9d21 Mon Sep 17 00:00:00 2001 From: Christian Inhetveen Date: Thu, 28 May 2026 13:37:55 +0200 Subject: [PATCH 2/8] TS-38628 Modern Paradigms and cleanup --- .../com/teamscale/jacoco/agent/Agent.kt | 5 +- .../teamscale/jacoco/agent/AgentResource.kt | 16 +-- .../teamscale/jacoco/agent/ResourceBase.kt | 15 +-- .../agent/logging/LogToTeamscaleAppender.kt | 101 +++++++----------- .../jacoco/agent/logging/LoggingUtils.kt | 3 +- .../agent/options/TeamscaleCredentials.kt | 2 +- .../agent/testimpact/TestwiseCoverageAgent.kt | 5 +- .../testimpact/TestwiseCoverageResource.kt | 22 ++-- buildSrc/src/main/kotlin/SystemTestPorts.kt | 12 +-- .../engine/executor/ImpactedTestsProvider.kt | 4 +- ...wiseCoverageCollectingExecutionListener.kt | 22 ++-- .../CucumberPickleDescriptorResolver.kt | 66 +++++------- .../ITestDescriptorResolver.kt | 9 +- ...nitClassBasedTestDescriptorResolverBase.kt | 14 ++- .../JUnitJupiterTestDescriptorResolver.kt | 8 +- .../JUnitPlatformSuiteDescriptorResolver.kt | 15 ++- .../JUnitVintageTestDescriptorResolver.kt | 2 +- .../test_descriptor/TestDescriptorUtils.kt | 16 ++- ...CoverageCollectingExecutionListenerTest.kt | 19 ++-- .../JUnitJupiterTestDescriptorResolverTest.kt | 6 +- .../report/testwise/model/ERevisionType.kt | 10 -- .../report/testwise/model/FileCoverage.kt | 2 +- .../report/testwise/model/PathCoverage.kt | 2 +- .../report/testwise/model/RevisionInfo.kt | 48 ++++----- .../report/util/LineRangeSerializer.kt | 5 +- 25 files changed, 163 insertions(+), 266 deletions(-) delete mode 100644 report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ERevisionType.kt diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt index a0a0cfce6..a3f33ab62 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt @@ -99,8 +99,9 @@ class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBas override fun initResourceConfig(): ResourceConfig? { val resourceConfig = ResourceConfig() resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString()) - AgentResource.setAgent(this) - return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java) + return resourceConfig + .register(AgentResource(this)) + .register(GenericExceptionMapper::class.java) } override fun prepareShutdown() { diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt index 94447f400..f97a54fab 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt @@ -8,7 +8,7 @@ import javax.ws.rs.core.Response * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [Agent]. */ @Path("/") -class AgentResource : ResourceBase() { +class AgentResource(private val agent: Agent) : ResourceBase(agent) { /** Handles dumping a XML coverage report for coverage collected until now. */ @POST @Path("/dump") @@ -26,16 +26,4 @@ class AgentResource : ResourceBase() { agent.controller.reset() return Response.noContent().build() } - - companion object { - private lateinit var agent: Agent - - /** - * Static setter to inject the [Agent] to the resource. - */ - fun setAgent(agent: Agent) { - Companion.agent = agent - agentBase = agent - } - } -} \ No newline at end of file +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt index 19b60e56e..22e7e83ad 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt @@ -13,19 +13,10 @@ import javax.ws.rs.core.Response /** * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase]. */ -abstract class ResourceBase { +abstract class ResourceBase(protected val agentBase: AgentBase) { /** The logger. */ protected val logger: Logger = LoggingUtils.getLogger(this) - companion object { - /** - * The agentBase inject via [AgentResource.setAgent] or - * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent]. - */ - @JvmStatic - protected lateinit var agentBase: AgentBase - } - @get:Path("/partition") @get:GET val partition: String @@ -119,7 +110,7 @@ abstract class ResourceBase { /** Returns revision information for the Teamscale upload. */ get() { val server = agentBase.options.teamscaleServer - return RevisionInfo(server.commit, server.revision) + return RevisionInfo.of(server.commit, server.revision) } /** @@ -131,4 +122,4 @@ abstract class ResourceBase { logger.error(message) throw BadRequestException(message) } -} \ No newline at end of file +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt index c2954b8fe..af340d475 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -12,11 +12,11 @@ import java.net.ConnectException import java.time.Duration import java.util.* import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.function.BiConsumer /** * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and @@ -27,41 +27,31 @@ class LogToTeamscaleAppender : AppenderBase() { private var profilerId: String? = null /** - * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was - * successful. + * Thread-safe buffer for unsent logs. Using [ConcurrentLinkedQueue] for lock-free producer-consumer access. */ - private val logBuffer = LinkedHashSet() + private val logBuffer = ConcurrentLinkedQueue() /** Scheduler for sending logs after the configured time interval */ private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r -> - // Make the thread a daemon so that it does not prevent the JVM from terminating. val t = Executors.defaultThreadFactory().newThread(r) t.setDaemon(true) t } - /** Active log flushing threads */ - private val activeLogFlushes: MutableSet> = - Collections.newSetFromMap(IdentityHashMap()) - - /** Is there a flush going on right now? */ - private val isFlusing = AtomicBoolean(false) + /** Active log flushing futures, tracked so [stop] can wait for them to finish. */ + private val activeLogFlushes = CopyOnWriteArrayList>() override fun start() { super.start() - scheduler.scheduleAtFixedRate({ - synchronized(activeLogFlushes) { - activeLogFlushes.removeIf { it.isDone } - if (activeLogFlushes.isEmpty()) flush() - } - }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS) + scheduler.scheduleAtFixedRate( + { flush() }, + FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS + ) } override fun append(eventObject: ILoggingEvent) { - synchronized(logBuffer) { - logBuffer.add(formatLog(eventObject)) - if (logBuffer.size >= BATCH_SIZE) flush() - } + logBuffer.add(formatLog(eventObject)) + if (logBuffer.size >= BATCH_SIZE) flush() } private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry { @@ -73,50 +63,35 @@ class LogToTeamscaleAppender : AppenderBase() { } private fun flush() { - sendLogs() - } - - /** Send logs in a separate thread */ - private fun sendLogs() { - synchronized(activeLogFlushes) { - activeLogFlushes.add(CompletableFuture.runAsync { - if (isFlusing.compareAndSet(false, true)) { - try { - val client = teamscaleClient ?: return@runAsync // There might be no connection configured. - - val logsToSend: MutableList - synchronized(logBuffer) { - logsToSend = logBuffer.toMutableList() - } - - val call = client.postProfilerLog(profilerId!!, logsToSend) - val response = call.execute() - check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } - - synchronized(logBuffer) { - // Removing the logs that have been sent after the fact. - // This handles problems with lost network connections. - logBuffer.removeAll(logsToSend.toSet()) - } - } catch (e: Exception) { - // We do not report on exceptions here. - if (e !is ConnectException) { - addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) - } - } finally { - isFlusing.set(false) - } - } - }.whenComplete(BiConsumer { _, _ -> - synchronized(activeLogFlushes) { - activeLogFlushes.removeIf { it.isDone } + val batch = mutableListOf() + var count = 0 + while (count < BATCH_SIZE) { + val entry = logBuffer.poll() ?: break + batch.add(entry) + count++ + } + if (batch.isEmpty()) return + + val future = CompletableFuture.runAsync { + val client = teamscaleClient ?: return@runAsync + + try { + val response = client.postProfilerLog(profilerId!!, batch).execute() + check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } + } catch (e: Exception) { + if (e is ConnectException) { + logBuffer.addAll(batch) + } else { + addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) + logBuffer.addAll(batch) } - })) + } } + future.whenComplete { _, _ -> activeLogFlushes.remove(future) } + activeLogFlushes.add(future) } override fun stop() { - // Already flush here once to make sure that we do not miss too much. flush() scheduler.shutdown() @@ -128,10 +103,8 @@ class LogToTeamscaleAppender : AppenderBase() { scheduler.shutdownNow() } - // A final flush after the scheduler has been shut down. flush() - // Block until all flushes are done CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join() super.stop() @@ -178,4 +151,4 @@ class LogToTeamscaleAppender : AppenderBase() { return true } } -} \ No newline at end of file +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt index 2a7c5851a..ef09b62e2 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt @@ -39,8 +39,7 @@ object LoggingUtils { /** * Returns the logger context. */ - val loggerContext: LoggerContext - get() = LoggerFactory.getILoggerFactory() as LoggerContext + val loggerContext: LoggerContext by lazy { LoggerFactory.getILoggerFactory() as LoggerContext } /** * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt index 7405c4ae3..2700185d7 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/TeamscaleCredentials.kt @@ -3,7 +3,7 @@ package com.teamscale.jacoco.agent.options import okhttp3.HttpUrl /** Credentials for accessing a Teamscale instance. */ -class TeamscaleCredentials( +data class TeamscaleCredentials( /** The URL of the Teamscale server. */ @JvmField val url: HttpUrl?, /** The user name used to authenticate against Teamscale. */ diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt index 3da3ee969..64eb4b29d 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgent.kt @@ -6,7 +6,6 @@ import com.teamscale.jacoco.agent.logging.LoggingUtils import com.teamscale.jacoco.agent.logging.LoggingUtils.wrap import com.teamscale.jacoco.agent.options.AgentOptions import com.teamscale.jacoco.agent.options.ETestwiseCoverageMode -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.Companion.setAgent import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator import org.glassfish.jersey.server.ResourceConfig import org.glassfish.jersey.server.ServerProperties @@ -41,8 +40,8 @@ class TestwiseCoverageAgent( override fun initResourceConfig(): ResourceConfig? { val resourceConfig = ResourceConfig() resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()) - setAgent(this) - return resourceConfig.register(TestwiseCoverageResource::class.java) + return resourceConfig + .register(TestwiseCoverageResource(this)) .register(GenericExceptionMapper::class.java) } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt index 3201b44ca..8cde33148 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageResource.kt @@ -16,12 +16,12 @@ import javax.ws.rs.core.Response * [TestwiseCoverageAgent]. */ @Path("/") -class TestwiseCoverageResource : ResourceBase() { +class TestwiseCoverageResource(private val testwiseCoverageAgent: TestwiseCoverageAgent) : ResourceBase(testwiseCoverageAgent) { @get:Path("/test") @get:GET val test: String? /** Returns the session ID of the current test. */ - get() = testwiseCoverageAgent?.controller?.sessionId + get() = testwiseCoverageAgent.controller.sessionId /** Handles the start of a new test case by setting the session ID. */ @POST @@ -31,7 +31,7 @@ class TestwiseCoverageResource : ResourceBase() { logger.debug("Start test {}", testId) - testwiseCoverageAgent?.testEventHandler?.testStart(testId!!) + testwiseCoverageAgent.testEventHandler.testStart(testId!!) return Response.noContent().build() } @@ -48,7 +48,7 @@ class TestwiseCoverageResource : ResourceBase() { logger.debug("End test {}", testId) - return testwiseCoverageAgent?.testEventHandler?.testEnd(testId!!, testExecution) + return testwiseCoverageAgent.testEventHandler.testEnd(testId!!, testExecution) } /** Handles the start of a new testrun. */ @@ -63,7 +63,7 @@ class TestwiseCoverageResource : ResourceBase() { @QueryParam("baseline") baseline: String?, @QueryParam("baseline-revision") baselineRevision: String?, availableTests: List? - ) = testwiseCoverageAgent?.testEventHandler?.testRunStart( + ) = testwiseCoverageAgent.testEventHandler.testRunStart( availableTests, includeNonImpactedTests, includeAddedTests, includeFailedAndSkipped, baseline, baselineRevision @@ -76,22 +76,12 @@ class TestwiseCoverageResource : ResourceBase() { fun handleTestRunEnd( @DefaultValue("false") @QueryParam("partial") partial: Boolean ): Response? { - testwiseCoverageAgent?.testEventHandler?.testRunEnd(partial) + testwiseCoverageAgent.testEventHandler.testRunEnd(partial) return Response.noContent().build() } companion object { /** Path parameter placeholder used in the HTTP requests. */ private const val TEST_ID_PARAMETER = "testId" - - private var testwiseCoverageAgent: TestwiseCoverageAgent? = null - - /** - * Static setter to inject the [TestwiseCoverageAgent] to the resource. - */ - fun setAgent(agent: TestwiseCoverageAgent) { - testwiseCoverageAgent = agent - agentBase = agent - } } } diff --git a/buildSrc/src/main/kotlin/SystemTestPorts.kt b/buildSrc/src/main/kotlin/SystemTestPorts.kt index ec3357341..ba87c00ae 100644 --- a/buildSrc/src/main/kotlin/SystemTestPorts.kt +++ b/buildSrc/src/main/kotlin/SystemTestPorts.kt @@ -6,6 +6,7 @@ import org.gradle.api.services.BuildServiceParameters import org.gradle.api.tasks.testing.Test import org.gradle.kotlin.dsl.the import java.io.Serializable +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject /** @@ -14,8 +15,7 @@ import javax.inject.Inject */ abstract class SystemTestPorts : BuildService { - private var nextFreePort = 6000 - private val lock = Object() + private val nextFreePort = AtomicInteger(6000) /** * Used ports should be unique for each system test to avoid collisions during parallel execution. @@ -23,13 +23,7 @@ abstract class SystemTestPorts : BuildService { * Since tests may run in different workers that don't share the #nextFreePort variable, we use the worker number * to avoid collisions (given that we don't have more than 100 system tests per worker). */ - fun pickFreePort(): Int { - synchronized(lock) { - val pickedPort = nextFreePort - nextFreePort += 1 - return pickedPort - } - } + fun pickFreePort(): Int = nextFreePort.getAndIncrement() companion object { diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/ImpactedTestsProvider.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/ImpactedTestsProvider.kt index 9dd41f11e..79fb9a41e 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/ImpactedTestsProvider.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/ImpactedTestsProvider.kt @@ -75,9 +75,9 @@ open class ImpactedTestsProvider( testClusters: List, availableTestDetails: List ): Boolean { - val returnedTests = testClusters.stream().mapToLong { + val returnedTests = testClusters.sumOf { it.tests?.size?.toLong() ?: 0 - }.sum() + } if (!includeNonImpacted) { LOG.info { "Received $returnedTests impacted tests of ${availableTestDetails.size} available tests." } return true diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListener.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListener.kt index 6bfc0c7e7..5a36ad9d9 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListener.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListener.kt @@ -12,7 +12,6 @@ import org.junit.platform.engine.UniqueId import org.junit.platform.engine.reporting.ReportEntry import java.io.PrintWriter import java.io.StringWriter -import java.util.* /** * An implementation of [EngineExecutionListener] that collects test-wise coverage information during @@ -55,7 +54,7 @@ class TestwiseCoverageCollectingExecutionListener( return } - testDescriptorResolver.getUniformPath(testDescriptor).ifPresent { testUniformPath -> + testDescriptorResolver.getUniformPath(testDescriptor)?.let { testUniformPath -> testExecutions.add( TestExecution( testUniformPath, @@ -70,7 +69,7 @@ class TestwiseCoverageCollectingExecutionListener( override fun executionStarted(testDescriptor: TestDescriptor) { if (testDescriptor.isRepresentative()) { - testDescriptorResolver.getUniformPath(testDescriptor).ifPresent { testUniformPath -> + testDescriptorResolver.getUniformPath(testDescriptor)?.let { testUniformPath -> teamscaleAgentNotifier.startTest(testUniformPath) } executionStartTime = System.currentTimeMillis() @@ -80,18 +79,15 @@ class TestwiseCoverageCollectingExecutionListener( override fun executionFinished(testDescriptor: TestDescriptor, testExecutionResult: TestExecutionResult) { if (testDescriptor.isRepresentative()) { - val uniformPath = testDescriptorResolver.getUniformPath(testDescriptor) - if (!uniformPath.isPresent) { - return - } + val uniformPath = testDescriptorResolver.getUniformPath(testDescriptor) ?: return val testExecution = getTestExecution( - testDescriptor, testExecutionResult, uniformPath.get() + testDescriptor, testExecutionResult, uniformPath ) if (testExecution != null) { testExecutions.add(testExecution) } - teamscaleAgentNotifier.endTest(uniformPath.get(), testExecution) + teamscaleAgentNotifier.endTest(uniformPath, testExecution) } else if (testDescriptor.parent.isPresent) { val testExecutionResults = testResultCache.computeIfAbsent( testDescriptor.parent.get().uniqueId @@ -117,7 +113,7 @@ class TestwiseCoverageCollectingExecutionListener( if (message.isNotEmpty()) { message.append("\n\n") } - message.append(executionResult.throwable.buildStacktrace()) + message.append(executionResult.throwable.orElse(null).buildStacktrace()) // Aggregate status here to most severe status according to SUCCESSFUL < ABORTED < FAILED if (status.ordinal < executionResult.status.ordinal) { status = executionResult.status @@ -162,12 +158,12 @@ class TestwiseCoverageCollectingExecutionListener( } /** Extracts the stacktrace from the given [Throwable] into a string or returns null if no throwable is given. */ - private fun Optional.buildStacktrace(): String? { - if (!isPresent) return null + private fun Throwable?.buildStacktrace(): String? { + this ?: return null val sw = StringWriter() val pw = PrintWriter(sw) - get().printStackTrace(pw) + printStackTrace(pw) return sw.toString() } diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.kt index 62b2ddd94..35edd2f27 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.kt @@ -4,48 +4,42 @@ import com.teamscale.test_impacted.commons.LoggerUtils.createLogger import com.teamscale.test_impacted.test_descriptor.TestDescriptorUtils.getUniqueIdSegment import org.junit.platform.engine.TestDescriptor import java.lang.reflect.Field -import java.util.* /** * Test descriptor resolver for Cucumber. For details how we extract the uniform path, see comment in * [getPickleName]. The cluster id is the .feature file in which the tests are defined. */ class CucumberPickleDescriptorResolver : ITestDescriptorResolver { - override fun getUniformPath(descriptor: TestDescriptor): Optional { - val featurePath = descriptor.featurePath() - LOG.fine { "Resolved feature: $featurePath" } - if (!featurePath.isPresent) { + override fun getUniformPath(descriptor: TestDescriptor): String? { + val featurePath = descriptor.featurePath() ?: run { LOG.severe { "Cannot resolve the feature classpath for ${descriptor}. This is probably a bug. Please report to CQSE" } - return Optional.empty() + return null } - val pickleName = descriptor.getPickleName() - LOG.fine { "Resolved pickle name: $pickleName" } - if (!pickleName.isPresent) { + val pickleName = descriptor.getPickleName() ?: run { LOG.severe { "Cannot resolve the pickle name for ${descriptor}. This is probably a bug. Please report to CQSE" } - return Optional.empty() + return null } - // Add an index to the end of the name in case multiple tests have the same name in the same feature file val featureDescriptor = descriptor.getFeatureFileTestDescriptor() - val indexSuffix = if (!featureDescriptor.isPresent) { + val indexSuffix = if (featureDescriptor == null) { "" } else { - val testsWithTheSameName = featureDescriptor.get().childrenWithPickleName(pickleName.get()) + val testsWithTheSameName = featureDescriptor.childrenWithPickleName(pickleName) " #${testsWithTheSameName.indexOf(descriptor) + 1}" } - val picklePath = "${featurePath.get()}/${pickleName.get()}" + val picklePath = "$featurePath/$pickleName" val uniformPath = (picklePath + indexSuffix).removeDuplicatedSlashes() LOG.fine { "Resolved uniform path: $uniformPath" } - return Optional.of(uniformPath) + return uniformPath } - override fun getClusterId(descriptor: TestDescriptor): Optional = - descriptor.featurePath().map { it.removeDuplicatedSlashes() } + override fun getClusterId(descriptor: TestDescriptor): String? = + descriptor.featurePath()?.removeDuplicatedSlashes() override val engineId = CUCUMBER_ENGINE_ID @@ -54,14 +48,14 @@ class CucumberPickleDescriptorResolver : ITestDescriptorResolver { * [feature:classpath%3Ahellocucumber%2Fcalculator.feature]/[scenario:11]/[examples:16]/[example:21] to * hellocucumber/calculator.feature/11/16/21 */ - private fun TestDescriptor.featurePath(): Optional { + private fun TestDescriptor.featurePath(): String? { LOG.fine { "Unique ID of cucumber test descriptor: $uniqueId" } - val featureSegment = getUniqueIdSegment(FEATURE_SEGMENT_TYPE) + val featureSegment = getUniqueIdSegment(FEATURE_SEGMENT_TYPE).orElse(null) LOG.fine { "Resolved feature segment: $featureSegment" } - return featureSegment.map { it.replace("classpath:".toRegex(), "") } + return featureSegment?.replace("classpath:".toRegex(), "") } - private fun TestDescriptor.getPickleName(): Optional { + private fun TestDescriptor.getPickleName(): String? { // The PickleDescriptor test descriptor class is not public, so we can't import and use it to get access to the pickle attribute containing the name => reflection // https://github.com/cucumber/cucumber-jvm/blob/main/cucumber-junit-platform-engine/src/main/java/io/cucumber/junit/platform/engine/NodeDescriptor.java#L90 // We want to use the name, though, because the unique id of the test descriptor can easily result in inconsistencies, @@ -104,21 +98,20 @@ class CucumberPickleDescriptorResolver : ITestDescriptorResolver { // getName() is required by the pickle interface val getNameMethod = pickle.javaClass.getDeclaredMethod("getName") getNameMethod.isAccessible = true - val name = getNameMethod.invoke(pickle).toString() - Optional.of(name) - .map { it.escapeSlashes() } - } ?: Optional.empty() - }.getOrNull() ?: Optional.empty() + getNameMethod.invoke(pickle).toString() + .escapeSlashes() + } + }.getOrNull() } - private fun TestDescriptor.getFeatureFileTestDescriptor(): Optional { + private fun TestDescriptor.getFeatureFileTestDescriptor(): TestDescriptor? { if (!isFeatureFileTestDescriptor()) { if (!parent.isPresent) { - return Optional.empty() + return null } return parent.get().getFeatureFileTestDescriptor() } - return Optional.of(this) + return this } private fun TestDescriptor.isFeatureFileTestDescriptor() = @@ -127,7 +120,7 @@ class CucumberPickleDescriptorResolver : ITestDescriptorResolver { private fun TestDescriptor.childrenWithPickleName(pickleName: String): List { if (children.isEmpty()) { val pickleId = getPickleName() - if (pickleId.isPresent && pickleName == pickleId.get()) { + if (pickleId != null && pickleName == pickleId) { return listOf(this) } return emptyList() @@ -146,22 +139,19 @@ class CucumberPickleDescriptorResolver : ITestDescriptorResolver { /** Type of the unique id segment of a test descriptor representing a cucumber feature file */ const val FEATURE_SEGMENT_TYPE = "feature" + private val ESCAPE_SLASHES_REGEX = """(?


- * If a slash is already escaped, no additional escaping is done. - * * * `/ -> \/` * * `\/ -> \/` - * */ - fun String.escapeSlashes() = - replace("(? + /** Returns the uniform path or `null` if no uniform path could be determined. */ + fun getUniformPath(descriptor: TestDescriptor): String? - /** Returns the uniform path or [Optional.empty] if no cluster id could be determined. */ - fun getClusterId(descriptor: TestDescriptor): Optional + /** Returns the cluster id or `null` if no cluster id could be determined. */ + fun getClusterId(descriptor: TestDescriptor): String? /** * Returns the [org.junit.platform.engine.TestEngine.getId] of the [org.junit.platform.engine.TestEngine] diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitClassBasedTestDescriptorResolverBase.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitClassBasedTestDescriptorResolverBase.kt index 40e097ca6..c2ee079c6 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitClassBasedTestDescriptorResolverBase.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitClassBasedTestDescriptorResolverBase.kt @@ -2,7 +2,6 @@ package com.teamscale.test_impacted.test_descriptor import com.teamscale.test_impacted.commons.LoggerUtils.createLogger import org.junit.platform.engine.TestDescriptor -import java.util.* /** Test descriptor resolver for JUnit based [org.junit.platform.engine.TestEngine]s. */ abstract class JUnitClassBasedTestDescriptorResolverBase : ITestDescriptorResolver { @@ -10,26 +9,25 @@ abstract class JUnitClassBasedTestDescriptorResolverBase : ITestDescriptorResolv private val LOG = createLogger() } - override fun getUniformPath(descriptor: TestDescriptor): Optional = - descriptor.getClassName().map { className -> + override fun getUniformPath(descriptor: TestDescriptor): String? = + descriptor.getClassName()?.let { className -> val dotName = className.replace(".", "/") "$dotName/${descriptor.legacyReportingName.trim { it <= ' ' }}" } - override fun getClusterId(descriptor: TestDescriptor): Optional { + override fun getClusterId(descriptor: TestDescriptor): String? { val classSegmentName = descriptor.getClassName() - if (!classSegmentName.isPresent) { + if (classSegmentName == null) { LOG.severe { "Falling back to unique ID as cluster id because class segment name could not be determined for test descriptor: $descriptor" } - // Default to uniform path. - return Optional.of(descriptor.uniqueId.toString()) + return descriptor.uniqueId.toString() } return classSegmentName } /** Returns the test class containing the test. */ - protected abstract fun TestDescriptor.getClassName(): Optional + protected abstract fun TestDescriptor.getClassName(): String? } diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolver.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolver.kt index bf2ac3276..01e5baf91 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolver.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolver.kt @@ -2,19 +2,17 @@ package com.teamscale.test_impacted.test_descriptor import com.teamscale.test_impacted.test_descriptor.TestDescriptorUtils.getUniqueIdSegment import org.junit.platform.engine.TestDescriptor -import java.util.* /** Test default test descriptor resolver for the JUnit jupiter [TestEngine]. */ class JUnitJupiterTestDescriptorResolver : JUnitClassBasedTestDescriptorResolverBase() { - override fun TestDescriptor.getClassName(): Optional { - val classSegment = getUniqueIdSegment(CLASS_SEGMENT_TYPE) - if (!classSegment.isPresent) return classSegment + override fun TestDescriptor.getClassName(): String? { + val classSegment = getUniqueIdSegment(CLASS_SEGMENT_TYPE).orElse(null) ?: return null val nestedClassNames = uniqueId.segments .filter { it.type == NESTED_CLASS_SEGMENT_TYPE } .joinToString("") { "\$${it.value}" } - return Optional.of(classSegment.get() + nestedClassNames) + return classSegment + nestedClassNames } override val engineId: String diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitPlatformSuiteDescriptorResolver.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitPlatformSuiteDescriptorResolver.kt index e239f682f..610a9940a 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitPlatformSuiteDescriptorResolver.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitPlatformSuiteDescriptorResolver.kt @@ -3,7 +3,6 @@ package com.teamscale.test_impacted.test_descriptor import com.teamscale.test_impacted.commons.LoggerUtils.createLogger import org.junit.platform.engine.TestDescriptor import org.junit.platform.engine.UniqueId -import java.util.* /** * Test descriptor resolver for JUnit Platform Suite test (c.f. @@ -31,14 +30,14 @@ class JUnitPlatformSuiteDescriptorResolver : ITestDescriptorResolver { private fun TestDescriptor.extractUniformPathOrClusterId( nameOfValueToExtractForLogs: String, - uniformPathOrClusterIdExtractor: (ITestDescriptorResolver) -> Optional - ): Optional { + uniformPathOrClusterIdExtractor: (ITestDescriptorResolver) -> String? + ): String? { val segments = uniqueId.segments if (verifySegments(segments)) { LOG.severe { "Assuming structure [engine:junit-platform-suite]/[suite:mySuite]/[engine:anotherEngine] for junit-platform-suite tests. Using $uniqueId as $nameOfValueToExtractForLogs as fallback." } - return Optional.of(uniqueId.toString()) + return uniqueId.toString() } val suite = segments[1].value.replace('.', '/') @@ -51,18 +50,18 @@ class JUnitPlatformSuiteDescriptorResolver : ITestDescriptorResolver { LOG.severe { "Cannot find a secondary engine nested under the junit-platform-suite engine (assuming structure [engine:junit-platform-suite]/[suite:mySuite]/[engine:anotherEngine]). Using $uniqueId as $nameOfValueToExtractForLogs as fallback." } - return Optional.of(uniqueId.toString()) + return uniqueId.toString() } val idOrUniformPath = uniformPathOrClusterIdExtractor(descriptorResolver) - if (!idOrUniformPath.isPresent) { + if (idOrUniformPath == null) { LOG.severe { "Secondary test descriptor resolver for engine ${secondaryEngineSegments.first().value} was not able to resolve the $nameOfValueToExtractForLogs. Using $uniqueId as fallback." } - return Optional.of(uniqueId.toString()) + return uniqueId.toString() } - return Optional.of("$suite/${idOrUniformPath.get()}") + return "$suite/$idOrUniformPath" } private fun verifySegments(segments: List) = diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitVintageTestDescriptorResolver.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitVintageTestDescriptorResolver.kt index a27f3fab2..4bc997c2e 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitVintageTestDescriptorResolver.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitVintageTestDescriptorResolver.kt @@ -6,7 +6,7 @@ import org.junit.platform.engine.TestDescriptor /** Test default test descriptor resolver for the JUnit vintage [org.junit.platform.engine.TestEngine]. */ class JUnitVintageTestDescriptorResolver : JUnitClassBasedTestDescriptorResolverBase() { override fun TestDescriptor.getClassName() = - getUniqueIdSegment(RUNNER_SEGMENT_TYPE) + getUniqueIdSegment(RUNNER_SEGMENT_TYPE).orElse(null) override val engineId: String get() = "junit-vintage" diff --git a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/TestDescriptorUtils.kt b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/TestDescriptorUtils.kt index fcf2e2ba6..cb6b8ba00 100644 --- a/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/TestDescriptorUtils.kt +++ b/impacted-test-engine/src/main/kotlin/com/teamscale/test_impacted/test_descriptor/TestDescriptorUtils.kt @@ -5,7 +5,6 @@ import com.teamscale.test_impacted.commons.IndentingWriter import com.teamscale.test_impacted.commons.LoggerUtils.createLogger import com.teamscale.test_impacted.engine.executor.AvailableTests import org.junit.platform.engine.TestDescriptor -import org.junit.platform.engine.UniqueId import org.junit.platform.engine.support.descriptor.ClassSource import org.junit.platform.engine.support.descriptor.MethodSource import java.util.* @@ -45,8 +44,7 @@ object TestDescriptorUtils { /** * Returns true if a [TestDescriptor] represents a test template or a test factory. * - * - * An example of a [UniqueId] of the [TestDescriptor] is: + * An example of a [org.junit.platform.engine.UniqueId] of the [TestDescriptor] is: * * * `[engine:junit-jupiter]/[class:com.example.project.JUnit5Test]/[test-template:withValueSource(java.lang.String)]` @@ -78,9 +76,7 @@ object TestDescriptorUtils { * be found. */ fun TestDescriptor.getUniqueIdSegment(type: String): Optional = - uniqueId.segments.stream() - .filter { it.type == type } - .findFirst().map { it.value } + Optional.ofNullable(uniqueId.segments.firstOrNull { it.type == type }?.value) /** Returns [com.teamscale.client.TestDetails.sourcePath] for a [TestDescriptor]. */ private fun TestDescriptor.source(): String? { @@ -110,21 +106,21 @@ object TestDescriptorUtils { val clusterId = testDescriptorResolver!!.getClusterId(testDescriptor) val uniformPath = testDescriptorResolver.getUniformPath(testDescriptor) - if (!uniformPath.isPresent) { + if (uniformPath == null) { LOG.severe { "Unable to determine uniform path for test descriptor: $testDescriptor" } return@forEach } - if (!clusterId.isPresent) { + if (clusterId == null) { LOG.severe { "Unable to determine cluster id path for test descriptor: $testDescriptor" } return@forEach } val testDetails = ClusteredTestDetails( - uniformPath.get(), + uniformPath, testDescriptor.source(), null, - clusterId.get() + clusterId ) availableTests.add(testDescriptor.uniqueId, testDetails) } diff --git a/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.kt b/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.kt index 75c884498..ae1e94016 100644 --- a/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.kt +++ b/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.kt @@ -9,7 +9,6 @@ import org.junit.platform.engine.EngineExecutionListener import org.junit.platform.engine.TestExecutionResult import org.junit.platform.engine.UniqueId import org.mockito.kotlin.* -import java.util.* /** Tests for [TestwiseCoverageCollectingExecutionListener]. */ internal class TestwiseCoverageCollectingExecutionListenerTest { @@ -38,13 +37,13 @@ internal class TestwiseCoverageCollectingExecutionListenerTest { val testRoot = SimpleTestDescriptor.testContainer(rootId, testClass) whenever(resolver.getUniformPath(impactedTestCase)) - .thenReturn(Optional.of("MyClass/impactedTestCase()")) + .thenReturn("MyClass/impactedTestCase()") whenever(resolver.getClusterId(impactedTestCase)) - .thenReturn(Optional.of("MyClass")) + .thenReturn("MyClass") whenever(resolver.getUniformPath(regularSkippedTestCase)) - .thenReturn(Optional.of("MyClass/regularSkippedTestCase()")) + .thenReturn("MyClass/regularSkippedTestCase()") whenever(resolver.getClusterId(regularSkippedTestCase)) - .thenReturn(Optional.of("MyClass")) + .thenReturn("MyClass") // Start engine and class. executionListener.executionStarted(testRoot) @@ -96,13 +95,13 @@ internal class TestwiseCoverageCollectingExecutionListenerTest { val testRoot = SimpleTestDescriptor.testContainer(rootId, testClass) whenever(resolver.getUniformPath(testCase1)) - .thenReturn(Optional.of("MyClass/testCase1()")) + .thenReturn("MyClass/testCase1()") whenever(resolver.getClusterId(testCase1)) - .thenReturn(Optional.of("MyClass")) + .thenReturn("MyClass") whenever(resolver.getUniformPath(testCase2)) - .thenReturn(Optional.of("MyClass/testCase2()")) + .thenReturn("MyClass/testCase2()") whenever(resolver.getClusterId(testCase2)) - .thenReturn(Optional.of("MyClass")) + .thenReturn("MyClass") // Start engine and class. executionListener.executionStarted(testRoot) @@ -125,4 +124,4 @@ internal class TestwiseCoverageCollectingExecutionListenerTest { Assertions.assertThat(testExecutions) .allMatch { it.result == ETestExecutionResult.SKIPPED } } -} \ No newline at end of file +} diff --git a/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolverTest.kt b/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolverTest.kt index 6adb0afbc..06e83c6e1 100644 --- a/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolverTest.kt +++ b/impacted-test-engine/src/test/kotlin/com/teamscale/test_impacted/test_descriptor/JUnitJupiterTestDescriptorResolverTest.kt @@ -19,7 +19,7 @@ internal class JUnitJupiterTestDescriptorResolverTest { .append(METHOD_SEGMENT_TYPE, "myMethod()") val descriptor = SimpleTestDescriptor.testCase(methodId) - assertThat(resolver.getUniformPath(descriptor)).hasValue("com/example/MyTest/myMethod()") + assertThat(resolver.getUniformPath(descriptor)).isEqualTo("com/example/MyTest/myMethod()") } @Test @@ -30,7 +30,7 @@ internal class JUnitJupiterTestDescriptorResolverTest { .append(METHOD_SEGMENT_TYPE, "testMethod()") val descriptor = SimpleTestDescriptor.testCase(methodId) - assertThat(resolver.getUniformPath(descriptor)).hasValue("com/example/OuterTest\$Inner/testMethod()") + assertThat(resolver.getUniformPath(descriptor)).isEqualTo("com/example/OuterTest\$Inner/testMethod()") } @Test @@ -42,6 +42,6 @@ internal class JUnitJupiterTestDescriptorResolverTest { .append(METHOD_SEGMENT_TYPE, "test()") val descriptor = SimpleTestDescriptor.testCase(methodId) - assertThat(resolver.getUniformPath(descriptor)).hasValue("com/example/A\$B\$C/test()") + assertThat(resolver.getUniformPath(descriptor)).isEqualTo("com/example/A\$B\$C/test()") } } diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ERevisionType.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ERevisionType.kt deleted file mode 100644 index 566aa148a..000000000 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ERevisionType.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.teamscale.report.testwise.model - -/** Type of revision information. */ -enum class ERevisionType { - /** Commit descriptor in the format branch:timestamp. */ - COMMIT, - - /** Source control revision, e.g. SVN revision or Git hash. */ - REVISION -} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/FileCoverage.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/FileCoverage.kt index 68d8ddd1d..158714c94 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/FileCoverage.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/FileCoverage.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty /** Holds coverage of a single file. */ -class FileCoverage @JsonCreator constructor( +data class FileCoverage @JsonCreator constructor( /** The name of the file. */ @JvmField @param:JsonProperty("fileName") val fileName: String, /** A list of line ranges that have been covered. */ diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/PathCoverage.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/PathCoverage.kt index 0f87fdeb7..d89973a02 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/PathCoverage.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/PathCoverage.kt @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty /** Container for [FileCoverage]s of the same path. */ -class PathCoverage @JsonCreator constructor( +data class PathCoverage @JsonCreator constructor( /** File system path. */ @param:JsonProperty("path") val path: String?, /** Files with coverage. */ diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/RevisionInfo.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/RevisionInfo.kt index 87a2a8ae8..ef065cc9f 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/RevisionInfo.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/RevisionInfo.kt @@ -1,35 +1,35 @@ package com.teamscale.report.testwise.model -import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo import com.teamscale.client.CommitDescriptor import java.io.Serializable /** Revision information necessary for uploading reports to Teamscale. */ -class RevisionInfo : Serializable { - /** The type of revision information. */ - val type: ERevisionType +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(value = RevisionInfo.Commit::class, name = "COMMIT"), + JsonSubTypes.Type(value = RevisionInfo.Revision::class, name = "REVISION") +) +sealed class RevisionInfo : Serializable { + /** Commit descriptor in the format branch:timestamp. */ + data class Commit( + @JsonProperty("value") val value: String + ) : RevisionInfo() - /** The value. Either a commit descriptor or a source control revision, depending on [type]. */ - val value: String? + /** Source control revision, e.g. SVN revision or Git hash. */ + data class Revision( + @JsonProperty("value") val value: String? + ) : RevisionInfo() - @JsonCreator - constructor(@JsonProperty("type") type: ERevisionType, @JsonProperty("value") value: String) { - this.type = type - this.value = value - } - - /** - * Constructor in case you have both fields, and either may be null. If both are set, the commit wins. If both are - * null, [type] will be [ERevisionType.REVISION] and [value] will be null. - */ - constructor(commit: CommitDescriptor?, revision: String?) { - if (commit == null) { - type = ERevisionType.REVISION - value = revision - } else { - type = ERevisionType.COMMIT - value = commit.toString() - } + companion object { + /** + * Creates a [RevisionInfo] from a commit descriptor or a revision string. + * If both are set, the commit wins. If both are null, returns [Revision] with null value. + */ + fun of(commit: CommitDescriptor?, revision: String?): RevisionInfo = + if (commit != null) Commit(commit.toString()) + else Revision(revision) } } diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/LineRangeSerializer.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/LineRangeSerializer.kt index fc4413385..2a728711a 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/LineRangeSerializer.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/LineRangeSerializer.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import java.io.IOException -import java.util.stream.Collectors /** * Custom Serializer to serialize [CompactLines] to string separated line ranges. See @@ -17,9 +16,7 @@ class LineRangeSerializer private constructor(t: Class?) : StdSeri @Throws(IOException::class) override fun serialize(value: CompactLines, jsonGen: JsonGenerator, provider: SerializerProvider) { val compactLineRanges = LineRangeStringParser.compactifyToRanges(value) - val concatenatedCompactLineRanges = compactLineRanges.stream().map { obj: LineRange -> obj.toString() } - .collect(Collectors.joining(",")) - jsonGen.writeString(concatenatedCompactLineRanges) + jsonGen.writeString(compactLineRanges.joinToString(",")) } companion object { From bd68bfb8ce0debc67c1b5841202ee8f95525cdfd Mon Sep 17 00:00:00 2001 From: Christian Inhetveen Date: Thu, 28 May 2026 13:41:23 +0200 Subject: [PATCH 3/8] TS-38628 Classpath caching regex precaching --- .../agent/LenientCoverageTransformer.kt | 22 ++++++++++++++++++- .../GitPropertiesLocatingTransformer.kt | 13 ++++++++--- .../util/ClasspathWildcardIncludeFilter.kt | 17 +++++++++----- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt index dd9094e71..278a3bc5c 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt @@ -25,6 +25,8 @@ class LenientCoverageTransformer( // We want to show our more specific error message instead, so we only log this for debugging at trace. IExceptionLogger { logger.trace(it.message, it) } ) { + private val useFastPathExclusion = options.includes.isNullOrBlank() + override fun transform( loader: ClassLoader?, classname: String, @@ -32,6 +34,9 @@ class LenientCoverageTransformer( protectionDomain: ProtectionDomain?, classfileBuffer: ByteArray ): ByteArray? { + if (useFastPathExclusion && isDefaultExcluded(classname)) { + return null + } try { return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer) } catch (e: IllegalClassFormatException) { @@ -45,7 +50,22 @@ class LenientCoverageTransformer( } companion object { + /** + * Derived from [com.teamscale.jacoco.agent.options.AgentOptions.DEFAULT_EXCLUDES]. + */ + private val DEFAULT_EXCLUDED_PREFIXES = setOf( + "java/", "javax/", "jakarta/", "sun/", "junit/", "shadow/", + "com/sun/", "com/fasterxml/", + "org/eclipse/", "org/junit/", "org/apache/", "org/slf4j/", + "org/gradle/", "org/jboss/", "org/wildfly/", "org/springframework/", + "org/aspectj/", "org/h2/", "org/hibernate/", "org/assertj/", + "org/mockito/", "org/thymeleaf/" + ) + + private fun isDefaultExcluded(classname: String): Boolean = + DEFAULT_EXCLUDED_PREFIXES.any { classname.startsWith(it) } + private fun getRootCauseMessage(e: Throwable): String? = e.cause?.let { getRootCauseMessage(it) } ?: e.message } -} \ No newline at end of file +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt index a0e1671aa..0b42a3884 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt @@ -5,6 +5,7 @@ import com.teamscale.report.util.ClasspathWildcardIncludeFilter import java.io.File import java.lang.instrument.ClassFileTransformer import java.security.ProtectionDomain +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentSkipListSet /** @@ -17,6 +18,7 @@ class GitPropertiesLocatingTransformer( ) : ClassFileTransformer { private val logger = getLogger(this) private val seenJars = ConcurrentSkipListSet() + private val classIncludedCache = ConcurrentHashMap() override fun transform( classLoader: ClassLoader?, @@ -26,12 +28,17 @@ class GitPropertiesLocatingTransformer( classFileContent: ByteArray? ): ByteArray? { if (protectionDomain == null) { - // happens for e.g. java.lang. We can ignore these classes return null } - if (className.isNullOrEmpty() || !locationIncludeFilter.isIncluded(className)) { - // only search in jar files of included classes + if (className.isNullOrEmpty()) { + return null + } + + val isIncluded = classIncludedCache.computeIfAbsent(className) { + locationIncludeFilter.isIncluded(className) + } + if (!isIncluded) { return null } diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt index d4af62566..ffafb1225 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt @@ -5,6 +5,7 @@ import com.teamscale.client.StringUtils import org.jacoco.core.runtime.WildcardMatcher import org.jacoco.report.JavaNames import java.util.* +import java.util.concurrent.ConcurrentHashMap /*** @@ -47,23 +48,29 @@ open class ClasspathWildcardIncludeFilter( */ fun isIncluded(path: String): Boolean { val className = getClassName(path) - // first check includes if (locationIncludeFilters != null && locationIncludeFilters?.matches(className) == false) { return false } - // if they match, check excludes return locationExcludeFilters == null || locationExcludeFilters?.matches(className) == false } companion object { + private val AT_REGEX = "@".toRegex() + private val JAVA_NAMES = JavaNames() + + private val classNameCache = ConcurrentHashMap(1024) + /** * Returns the normalized class name of the given class file's path. I.e. turns something like * "/opt/deploy/some.jar@com/teamscale/Class.class" into something like "com.teamscale.Class". */ - fun getClassName(path: String): String { + fun getClassName(path: String): String = + classNameCache.computeIfAbsent(path) { computeClassName(it) } + + private fun computeClassName(path: String): String { val parts = FileSystemUtils.normalizeSeparators(path) - .split("@".toRegex()).dropLastWhile { it.isEmpty() } + .split(AT_REGEX).dropLastWhile { it.isEmpty() } .toTypedArray() if (parts.isEmpty()) { return "" @@ -73,7 +80,7 @@ open class ClasspathWildcardIncludeFilter( if (path.lowercase(Locale.getDefault()).endsWith(".class")) { pathInsideJar = StringUtils.removeLastPart(pathInsideJar, '.') } - return JavaNames().getQualifiedClassName(pathInsideJar) + return JAVA_NAMES.getQualifiedClassName(pathInsideJar) } } } From bd00464e991efad7a9959dd4b5e53b9243ea9de6 Mon Sep 17 00:00:00 2001 From: Christian Inhetveen Date: Thu, 28 May 2026 16:02:57 +0200 Subject: [PATCH 4/8] TS-38628 Fix self instrumentation and some minor issues --- .../agent/LenientCoverageTransformer.kt | 28 ++----------------- .../jacoco/agent/options/AgentOptions.kt | 7 +++-- .../options/JacocoAgentOptionsBuilder.kt | 2 +- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 6 ---- teamscale-gradle-plugin/build.gradle.kts | 1 + .../aggregation/ReportAggregationPlugin.kt | 1 + .../kotlin/com/teamscale/config/Commit.kt | 1 + .../kotlin/com/teamscale/utils/Reports.kt | 2 ++ 9 files changed, 14 insertions(+), 36 deletions(-) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt index 278a3bc5c..7bcfef361 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt @@ -2,31 +2,27 @@ package com.teamscale.jacoco.agent import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger -import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime import org.slf4j.Logger import java.lang.instrument.IllegalClassFormatException import java.security.ProtectionDomain /** - * A class file transformer which delegates to the JaCoCo [org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer] to do the actual instrumentation, + * A class file transformer which delegates to the JaCoCo [CoverageTransformer] to do the actual instrumentation, * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in * the collected coverage report. */ class LenientCoverageTransformer( runtime: IRuntime?, - options: AgentOptions, + options: org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions, private val logger: Logger ) : CoverageTransformer( - runtime, - options, + runtime, options, // The coverage transformer only uses the logger to print an error when the instrumentation fails. // We want to show our more specific error message instead, so we only log this for debugging at trace. IExceptionLogger { logger.trace(it.message, it) } ) { - private val useFastPathExclusion = options.includes.isNullOrBlank() - override fun transform( loader: ClassLoader?, classname: String, @@ -34,9 +30,6 @@ class LenientCoverageTransformer( protectionDomain: ProtectionDomain?, classfileBuffer: ByteArray ): ByteArray? { - if (useFastPathExclusion && isDefaultExcluded(classname)) { - return null - } try { return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer) } catch (e: IllegalClassFormatException) { @@ -50,21 +43,6 @@ class LenientCoverageTransformer( } companion object { - /** - * Derived from [com.teamscale.jacoco.agent.options.AgentOptions.DEFAULT_EXCLUDES]. - */ - private val DEFAULT_EXCLUDED_PREFIXES = setOf( - "java/", "javax/", "jakarta/", "sun/", "junit/", "shadow/", - "com/sun/", "com/fasterxml/", - "org/eclipse/", "org/junit/", "org/apache/", "org/slf4j/", - "org/gradle/", "org/jboss/", "org/wildfly/", "org/springframework/", - "org/aspectj/", "org/h2/", "org/hibernate/", "org/assertj/", - "org/mockito/", "org/thymeleaf/" - ) - - private fun isDefaultExcluded(classname: String): Boolean = - DEFAULT_EXCLUDED_PREFIXES.any { classname.startsWith(it) } - private fun getRootCauseMessage(e: Throwable): String? = e.cause?.let { getRootCauseMessage(it) } ?: e.message } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt index b30735d89..2736385f7 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt @@ -639,8 +639,9 @@ open class AgentOptions(private val logger: ILogger) { /** Returns whether the config indicates to use Test Impact mode. */ fun useTestwiseCoverageMode() = mode == EMode.TESTWISE - val locationIncludeFilter: ClasspathWildcardIncludeFilter - get() = ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes) + val locationIncludeFilter: ClasspathWildcardIncludeFilter by lazy { + ClasspathWildcardIncludeFilter(jacocoIncludes, jacocoExcludes) + } /** Whether coverage should be dumped in regular intervals. */ fun shouldDumpInIntervals() = dumpIntervalInMinutes > 0 @@ -695,7 +696,7 @@ open class AgentOptions(private val logger: ILogger) { * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale. */ const val DEFAULT_EXCLUDES = - "shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*" + "com.teamscale.*:kotlin.*:shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*" /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */ const val GIT_PROPERTIES_JAR_OPTION = "git-properties-jar" diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt index 78c7cd5e7..aa8cc25e3 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/JacocoAgentOptionsBuilder.kt @@ -21,7 +21,7 @@ class JacocoAgentOptionsBuilder(private val agentOptions: AgentOptions) { builder.append(",includes=").append(agentOptions.jacocoIncludes) } - logger.debug("Using default excludes: ${AgentOptions.DEFAULT_EXCLUDES}") + logger.info("Excluding ${AgentOptions.DEFAULT_EXCLUDES.split(":").size} package prefixes from instrumentation: ${AgentOptions.DEFAULT_EXCLUDES}") builder.append(",excludes=").append(agentOptions.jacocoExcludes) // Don't dump class files in testwise mode when coverage is written to an exec file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a15215b8f..fcf06e4ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,6 +89,6 @@ gitProperties = { id = "com.gorylenko.gradle-git-properties", version = "3.0.3" mavenPluginDevelopment = { id = "org.gradlex.maven-plugin-development", version = "1.0.3" } shadow = { id = "com.gradleup.shadow", version = "9.4.2" } oci = { id = "io.github.sgtsilvio.gradle.oci", version = "0.27.0" } -kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version = "2.3.21" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version = "2.3.20" } jlink = { id = "org.beryx.jlink", version = "4.0.1" } testRetry = { id = "org.gradle.test-retry", version = "1.6.5" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 235f7a2c8..2788b3700 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,3 @@ -pluginManagement { - plugins { - kotlin("jvm") version "2.3.21" - } -} - plugins { id("org.gradle.toolchains.foojay-resolver-convention") version("1.0.0") id("io.github.sgtsilvio.gradle.oci") version("0.27.0") diff --git a/teamscale-gradle-plugin/build.gradle.kts b/teamscale-gradle-plugin/build.gradle.kts index 08ac021c3..285b8e9e6 100644 --- a/teamscale-gradle-plugin/build.gradle.kts +++ b/teamscale-gradle-plugin/build.gradle.kts @@ -63,6 +63,7 @@ gradlePlugin { dependencies { implementation(project(":teamscale-client")) implementation(project(":report-generator")) + implementation(libs.jacoco.core) implementation(gradleApi()) implementation(libs.jgit) implementation(libs.jackson.databind) diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/aggregation/ReportAggregationPlugin.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/aggregation/ReportAggregationPlugin.kt index 613a3b2f0..b0ba46a8e 100755 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/aggregation/ReportAggregationPlugin.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/aggregation/ReportAggregationPlugin.kt @@ -48,6 +48,7 @@ abstract class ReportAggregationPlugin : Plugin { @get:Inject protected abstract val jvmPluginServices: JvmPluginServices + @Suppress("DEPRECATION") override fun apply(project: Project) { val configurations = project.configurations val reportAggregation = diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/Commit.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/Commit.kt index 1a3f78121..8d8d50bd3 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/Commit.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/Commit.kt @@ -56,6 +56,7 @@ abstract class Commit @Inject constructor( /** * Provides a combined provider that resolves to the branch and timestamp if given or to the revision otherwise. */ + @Suppress("DEPRECATION") internal val combined: Provider by lazy { val commitProvider: Provider = providers.zip(branchName, timestamp) { branch, timestamp -> diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/utils/Reports.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/utils/Reports.kt index fec34d312..051680b04 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/utils/Reports.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/utils/Reports.kt @@ -231,6 +231,8 @@ open class Reports(objectFactory: ObjectFactory, clazz: Class) : return reports.collectionSchema } + @Deprecated("Gradle API has changed", ReplaceWith("findAll(spec)")) + @Suppress("DEPRECATION") override fun findAll(spec: Closure): MutableSet { return reports.findAll(spec) } From cffb4ba0c687b17ddbd6a43ebd820435888dedf5 Mon Sep 17 00:00:00 2001 From: Constructor Date: Mon, 1 Jun 2026 12:45:51 +0200 Subject: [PATCH 5/8] TS-38628 Review --- CHANGELOG.md | 1 + .../agent/LenientCoverageTransformer.kt | 3 ++- .../GitPropertiesLocatingTransformer.kt | 1 + .../agent/logging/LogToTeamscaleAppender.kt | 25 ++++++++++++------- renovate.json | 6 +++++ .../agent/options}/ClasspathUtils.kt | 0 .../agent/options}/FilePatternResolver.kt | 6 ++--- 7 files changed, 28 insertions(+), 14 deletions(-) rename report-generator/src/main/kotlin/com/teamscale/{jacoco.agent.options => jacoco/agent/options}/ClasspathUtils.kt (100%) rename report-generator/src/main/kotlin/com/teamscale/{jacoco.agent.options => jacoco/agent/options}/FilePatternResolver.kt (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c6bbd00..a54ae9e72 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 +- [breaking] _report-generator_: `RevisionInfo` is now a sealed class with polymorphic Jackson serialization (`@JsonTypeInfo` / `@JsonSubTypes`). The JSON representation now uses "COMMIT" and "REVISION" as type discriminator values instead of the previous `ERevisionType` enum names. # 36.5.2 - [security fix] _agent_: The Teamscale access token was logged in clear text in DEBUG-level logs (e.g., when `debug=true` was set) and in the WARN-level log emitted when multiple `-javaagent` arguments are present. The token is now obfuscated in those logs as well, matching INFO-level behavior. diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt index 7bcfef361..eb0f2b5f6 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt @@ -2,6 +2,7 @@ package com.teamscale.jacoco.agent import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger +import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime import org.slf4j.Logger import java.lang.instrument.IllegalClassFormatException @@ -15,7 +16,7 @@ import java.security.ProtectionDomain */ class LenientCoverageTransformer( runtime: IRuntime?, - options: org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions, + options: AgentOptions, private val logger: Logger ) : CoverageTransformer( runtime, options, diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt index 0b42a3884..58a6b8cda 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatingTransformer.kt @@ -28,6 +28,7 @@ class GitPropertiesLocatingTransformer( classFileContent: ByteArray? ): ByteArray? { if (protectionDomain == null) { + // happens for e.g. java.lang. We can ignore these classes return null } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt index af340d475..ef3c916fa 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -12,11 +12,12 @@ import java.net.ConnectException import java.time.Duration import java.util.* import java.util.concurrent.CompletableFuture -import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.ConcurrentLinkedDeque import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger /** * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and @@ -27,12 +28,16 @@ class LogToTeamscaleAppender : AppenderBase() { private var profilerId: String? = null /** - * Thread-safe buffer for unsent logs. Using [ConcurrentLinkedQueue] for lock-free producer-consumer access. + * Thread-safe buffer for unsent logs. Using [ConcurrentLinkedDeque] for lock-free producer-consumer access. */ - private val logBuffer = ConcurrentLinkedQueue() + private val logBuffer = ConcurrentLinkedDeque() + + /** Approximate count of entries in [logBuffer], avoiding O(n) [ConcurrentLinkedDeque.size] calls. */ + private val logBufferSize = AtomicInteger(0) /** Scheduler for sending logs after the configured time interval */ private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r -> + // Make the thread a daemon so that it does not prevent the JVM from terminating. val t = Executors.defaultThreadFactory().newThread(r) t.setDaemon(true) t @@ -51,7 +56,7 @@ class LogToTeamscaleAppender : AppenderBase() { override fun append(eventObject: ILoggingEvent) { logBuffer.add(formatLog(eventObject)) - if (logBuffer.size >= BATCH_SIZE) flush() + if (logBufferSize.incrementAndGet() >= BATCH_SIZE) flush() } private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry { @@ -69,6 +74,7 @@ class LogToTeamscaleAppender : AppenderBase() { val entry = logBuffer.poll() ?: break batch.add(entry) count++ + logBufferSize.decrementAndGet() } if (batch.isEmpty()) return @@ -79,16 +85,17 @@ class LogToTeamscaleAppender : AppenderBase() { val response = client.postProfilerLog(profilerId!!, batch).execute() check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } } catch (e: Exception) { - if (e is ConnectException) { - logBuffer.addAll(batch) - } else { + if (e !is ConnectException) { addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) - logBuffer.addAll(batch) } + batch.asReversed().forEach { entry -> + logBuffer.push(entry) + } + logBufferSize.addAndGet(batch.size) } } - future.whenComplete { _, _ -> activeLogFlushes.remove(future) } activeLogFlushes.add(future) + future.whenComplete { _, _ -> activeLogFlushes.remove(future) } } override fun stop() { diff --git a/renovate.json b/renovate.json index d0e7ec26f..3b0f66862 100644 --- a/renovate.json +++ b/renovate.json @@ -67,6 +67,12 @@ "com.teamscale:teamscale-client" ], "enabled": false + }, + { + "matchPackageNames": [ + "org.jetbrains.kotlin.jvm" + ], + "enabled": false } ] } diff --git a/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt b/report-generator/src/main/kotlin/com/teamscale/jacoco/agent/options/ClasspathUtils.kt similarity index 100% rename from report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/ClasspathUtils.kt rename to report-generator/src/main/kotlin/com/teamscale/jacoco/agent/options/ClasspathUtils.kt diff --git a/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt b/report-generator/src/main/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolver.kt similarity index 97% rename from report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt rename to report-generator/src/main/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolver.kt index bc366dd07..35cbd1951 100644 --- a/report-generator/src/main/kotlin/com/teamscale/jacoco.agent.options/FilePatternResolver.kt +++ b/report-generator/src/main/kotlin/com/teamscale/jacoco/agent/options/FilePatternResolver.kt @@ -5,12 +5,10 @@ import com.teamscale.client.FileSystemUtils.normalizeSeparators import com.teamscale.report.util.ILogger import java.io.File import java.io.IOException -import java.nio.file.Files import java.nio.file.InvalidPathException import java.nio.file.Path import java.nio.file.Paths -import java.util.function.Predicate -import java.util.stream.Collectors +import kotlin.io.path.PathWalkOption import kotlin.io.path.walk /** Helper class to support resolving file paths which may contain Ant patterns. */ @@ -149,7 +147,7 @@ class FilePatternResolver(private val logger: ILogger) { val pathRegex = AntPatternUtils.convertPattern(suffixPattern, false) try { - matchingPaths = basePath.walk().filter { + matchingPaths = basePath.walk(PathWalkOption.INCLUDE_DIRECTORIES).filter { pathRegex.matcher(normalizeSeparators(basePath.relativize(it).toString())).matches() }.sorted().toList() } catch (e: IOException) { From bad7a32458590c631d26193cc9798f5ec9e7af73 Mon Sep 17 00:00:00 2001 From: Constructor Date: Mon, 1 Jun 2026 16:19:21 +0200 Subject: [PATCH 6/8] TS-38628 Channel Logger --- agent/build.gradle.kts | 1 + .../agent/logging/LogToTeamscaleAppender.kt | 143 ++++++++---------- gradle/libs.versions.toml | 2 + 3 files changed, 65 insertions(+), 81 deletions(-) diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index b047547f8..855db1d18 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(libs.jackson.databind) implementation(libs.jetbrains.annotations) + implementation(libs.coroutines.core) testImplementation(project(":tia-client")) testImplementation(libs.retrofit.converter.jackson) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt index ef3c916fa..da1977e0d 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -8,112 +8,93 @@ import ch.qos.logback.core.status.ErrorStatus import com.teamscale.client.ITeamscaleService import com.teamscale.client.ProfilerLogEntry import com.teamscale.jacoco.agent.options.AgentOptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.time.withTimeoutOrNull +import kotlinx.coroutines.withTimeoutOrNull import java.net.ConnectException import java.time.Duration -import java.util.* -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ConcurrentLinkedDeque -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.coroutineContext /** - * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and - * sends them later. + * Custom log appender that sends logs to Teamscale; it buffers logs that were not sent due to connection issues and + * sends them later. Uses a [Channel] as a lock-free producer-consumer buffer with a coroutine collector for batching. */ class LogToTeamscaleAppender : AppenderBase() { - /** The unique ID of the profiler */ + /** The unique ID of the profiler. */ private var profilerId: String? = null - /** - * Thread-safe buffer for unsent logs. Using [ConcurrentLinkedDeque] for lock-free producer-consumer access. - */ - private val logBuffer = ConcurrentLinkedDeque() + /** Lock-free channel for log entries. [Channel.trySend] is called from Logback threads, [Channel.receive] from the collector coroutine. */ + private val logChannel = Channel(Channel.UNLIMITED) - /** Approximate count of entries in [logBuffer], avoiding O(n) [ConcurrentLinkedDeque.size] calls. */ - private val logBufferSize = AtomicInteger(0) + /** Structured concurrency scope backing the collector coroutine. */ + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - /** Scheduler for sending logs after the configured time interval */ - private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r -> - // Make the thread a daemon so that it does not prevent the JVM from terminating. - val t = Executors.defaultThreadFactory().newThread(r) - t.setDaemon(true) - t - } - - /** Active log flushing futures, tracked so [stop] can wait for them to finish. */ - private val activeLogFlushes = CopyOnWriteArrayList>() + /** The collector coroutine job, tracked so [stop] can wait for it to finish. */ + private var collectorJob: Job? = null override fun start() { super.start() - scheduler.scheduleAtFixedRate( - { flush() }, - FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS - ) + collectorJob = scope.launch { collectAndSend() } } override fun append(eventObject: ILoggingEvent) { - logBuffer.add(formatLog(eventObject)) - if (logBufferSize.incrementAndGet() >= BATCH_SIZE) flush() + logChannel.trySend(formatLog(eventObject)) } - private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry { - val trace = LoggingUtils.getStackTraceFromEvent(eventObject) - val timestamp = eventObject.timeStamp - val message = eventObject.formattedMessage - val severity = eventObject.level.toString() - return ProfilerLogEntry(timestamp, message, trace, severity) - } + private fun formatLog(eventObject: ILoggingEvent) = ProfilerLogEntry( + eventObject.timeStamp, + eventObject.formattedMessage, + LoggingUtils.getStackTraceFromEvent(eventObject), + eventObject.level.toString() + ) - private fun flush() { - val batch = mutableListOf() - var count = 0 - while (count < BATCH_SIZE) { - val entry = logBuffer.poll() ?: break - batch.add(entry) - count++ - logBufferSize.decrementAndGet() - } - if (batch.isEmpty()) return - - val future = CompletableFuture.runAsync { - val client = teamscaleClient ?: return@runAsync - - try { - val response = client.postProfilerLog(profilerId!!, batch).execute() - check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } - } catch (e: Exception) { - if (e !is ConnectException) { - addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) - } - batch.asReversed().forEach { entry -> - logBuffer.push(entry) + /** + * Collector coroutine: receives entries from [logChannel], batches them up to [BATCH_SIZE], and sends them + * via [sendBatch]. If no entries arrive within [FLUSH_INTERVAL], a new cycle begins (no empty batch is sent). + * Exits when the scope is cancelled or the channel is closed and empty. + */ + private suspend fun collectAndSend() { + while (currentCoroutineContext().isActive) { + val batch = buildList { + val entry = withTimeoutOrNull(FLUSH_INTERVAL) { + logChannel.receive() + } ?: return@buildList + add(entry) + repeat(BATCH_SIZE - 1) { + logChannel.tryReceive().getOrNull()?.let { add(it) } ?: return@buildList } - logBufferSize.addAndGet(batch.size) } + if (batch.isNotEmpty()) sendBatch(batch) } - activeLogFlushes.add(future) - future.whenComplete { _, _ -> activeLogFlushes.remove(future) } } - override fun stop() { - flush() - - scheduler.shutdown() + /** Posts the given [batch] to Teamscale. On failure, re-enqueues entries back into [logChannel]. */ + private fun sendBatch(batch: List) { + val client = teamscaleClient ?: return try { - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow() + val response = client.postProfilerLog(profilerId!!, batch).execute() + check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } + } catch (e: Exception) { + if (e !is ConnectException) { + addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) } - } catch (_: InterruptedException) { - scheduler.shutdownNow() + batch.forEach { logChannel.trySend(it) } } + } - flush() - - CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join() - + override fun stop() { + logChannel.close() + runBlocking { collectorJob?.join() } + scope.cancel() super.stop() } @@ -126,13 +107,13 @@ class LogToTeamscaleAppender : AppenderBase() { } companion object { - /** Flush the logs after N elements are in the queue */ + /** Flush the logs after N elements are in the queue. */ private const val BATCH_SIZE = 50 - /** Flush the logs in the given time interval */ + /** Flush the logs in the given time interval. */ private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3) - /** The service client for sending logs to Teamscale */ + /** The service client for sending logs to Teamscale. */ private var teamscaleClient: ITeamscaleService? = null /** diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fcf06e4ce..c93366ef3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ mockitoKotlin = "6.3.0" picocli = "4.7.7" maven = "3.9.16" asm = "9.10.1" +coroutines = "1.10.2" [libraries] jetty-bom = { module = "org.eclipse.jetty:jetty-bom", version = "9.4.58.v20250814" } @@ -81,6 +82,7 @@ asm-core = { module = "org.ow2.asm:asm", version.ref = "asm" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" } jetbrains-annotations = { module = "org.jetbrains:annotations", version = "26.1.0" } +coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } [plugins] nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version = "2.0.0" } From f8f0dc86a65060dfa4f26c84441940512e24e3bc Mon Sep 17 00:00:00 2001 From: Constructor Date: Mon, 1 Jun 2026 16:34:47 +0200 Subject: [PATCH 7/8] TS-38628 Safetymeasures --- .../agent/logging/LogToTeamscaleAppender.kt | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt index da1977e0d..59ef2a427 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.time.delay import kotlinx.coroutines.time.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull import java.net.ConnectException @@ -33,7 +36,7 @@ class LogToTeamscaleAppender : AppenderBase() { private var profilerId: String? = null /** Lock-free channel for log entries. [Channel.trySend] is called from Logback threads, [Channel.receive] from the collector coroutine. */ - private val logChannel = Channel(Channel.UNLIMITED) + private val logChannel = Channel(capacity = BUFFER_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST) /** Structured concurrency scope backing the collector coroutine. */ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -58,42 +61,56 @@ class LogToTeamscaleAppender : AppenderBase() { ) /** - * Collector coroutine: receives entries from [logChannel], batches them up to [BATCH_SIZE], and sends them - * via [sendBatch]. If no entries arrive within [FLUSH_INTERVAL], a new cycle begins (no empty batch is sent). - * Exits when the scope is cancelled or the channel is closed and empty. + * Collector coroutine: drains [logChannel] into batches, sends them to Teamscale, and retries with backoff on + * failure. */ private suspend fun collectAndSend() { + val batch = mutableListOf() + while (currentCoroutineContext().isActive) { - val batch = buildList { + if (batch.isEmpty()) { val entry = withTimeoutOrNull(FLUSH_INTERVAL) { logChannel.receive() - } ?: return@buildList - add(entry) - repeat(BATCH_SIZE - 1) { - logChannel.tryReceive().getOrNull()?.let { add(it) } ?: return@buildList + } ?: continue + batch.add(entry) + } + + while (batch.size < BATCH_SIZE) { + logChannel.tryReceive().getOrNull()?.let { batch.add(it) } ?: break + } + + if (batch.isNotEmpty()) { + if (sendBatch(batch)) { + batch.clear() + } else { + delay(RETRY_BACKOFF) } } - if (batch.isNotEmpty()) sendBatch(batch) } } - /** Posts the given [batch] to Teamscale. On failure, re-enqueues entries back into [logChannel]. */ - private fun sendBatch(batch: List) { - val client = teamscaleClient ?: return - try { + /** + * Posts the given [batch] to Teamscale. + * + * @return `true` if the batch was sent successfully, `false` if it should be retried. + */ + private fun sendBatch(batch: List): Boolean { + val client = teamscaleClient ?: return true + return try { val response = client.postProfilerLog(profilerId!!, batch).execute() check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } + true } catch (e: Exception) { if (e !is ConnectException) { addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) } - batch.forEach { logChannel.trySend(it) } + false } } override fun stop() { logChannel.close() - runBlocking { collectorJob?.join() } + runBlocking { withTimeoutOrNull(SHUTDOWN_TIMEOUT) { collectorJob?.join() } } scope.cancel() super.stop() } @@ -107,12 +124,21 @@ class LogToTeamscaleAppender : AppenderBase() { } companion object { + /** Maximum number of log entries held in memory. Older entries are dropped on overflow. */ + private const val BUFFER_CAPACITY = 10_000 + /** Flush the logs after N elements are in the queue. */ private const val BATCH_SIZE = 50 /** Flush the logs in the given time interval. */ private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3) + /** Backoff duration before retrying a failed batch. */ + private val RETRY_BACKOFF: Duration = Duration.ofSeconds(5) + + /** Maximum time to wait for the collector to drain during shutdown. */ + private val SHUTDOWN_TIMEOUT: Duration = Duration.ofSeconds(3) + /** The service client for sending logs to Teamscale. */ private var teamscaleClient: ITeamscaleService? = null From e820c3f2217a184050c3c605b9d23839e66f64b8 Mon Sep 17 00:00:00 2001 From: Constructor Date: Wed, 3 Jun 2026 21:01:57 +0200 Subject: [PATCH 8/8] TS-38628 Fix unsafe channel receive termination --- .../jacoco/agent/logging/LogToTeamscaleAppender.kt | 8 +++----- .../com/teamscale/jacoco/agent/options/AgentOptions.kt | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt index 59ef2a427..c63b8959e 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -16,16 +16,13 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.time.delay import kotlinx.coroutines.time.withTimeoutOrNull -import kotlinx.coroutines.withTimeoutOrNull import java.net.ConnectException import java.time.Duration -import kotlin.coroutines.coroutineContext /** * Custom log appender that sends logs to Teamscale; it buffers logs that were not sent due to connection issues and @@ -69,9 +66,10 @@ class LogToTeamscaleAppender : AppenderBase() { while (currentCoroutineContext().isActive) { if (batch.isEmpty()) { - val entry = withTimeoutOrNull(FLUSH_INTERVAL) { - logChannel.receive() + val receiveResult = withTimeoutOrNull(FLUSH_INTERVAL) { + logChannel.receiveCatching() } ?: continue + val entry = receiveResult.getOrNull() ?: break batch.add(entry) } diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt index 2736385f7..05daba11a 100644 --- a/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/options/AgentOptions.kt @@ -696,7 +696,7 @@ open class AgentOptions(private val logger: ILogger) { * debugging traces easier and reduces trace size and warnings about unmatched classes in Teamscale. */ const val DEFAULT_EXCLUDES = - "com.teamscale.*:kotlin.*:shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*" + "kotlin.*:shadow.*:com.sun.*:sun.*:org.eclipse.*:org.junit.*:junit.*:org.apache.*:org.slf4j.*:javax.*:org.gradle.*:java.*:org.jboss.*:org.wildfly.*:org.springframework.*:com.fasterxml.*:jakarta.*:org.aspectj.*:org.h2.*:org.hibernate.*:org.assertj.*:org.mockito.*:org.thymeleaf.*" /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */ const val GIT_PROPERTIES_JAR_OPTION = "git-properties-jar"