diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef04cba4..642e0e571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ We use [semantic versioning](http://semver.org/): - PATCH version when you make backwards compatible bug fixes. # Next version +- [fix] _agent_: Warning about multiple agents being present is misleading + +# 34.2.0 +- [fix] _agent_: `search-git-properties-recursively` was not considered when jar was given via `artifactory-git-properties-jar` +- [deprecation] _agent_: `artifactory-git-properties-jar` is deprecated now. Replace the option with `git-properties-jar`. The old option still works but is an alias now for `git-properties-jar`. +- [feature] _agent_: Added `git-properties-commit-date-format` (replaces `artifactory-git-properties-commit-date-format`), which now also can be used in non-artifactory cases. +- [fix] _agent_: `search-git-properties-recursively` did only consider nested jar files, but no `war`, `ear`, `aar` + +# 34.1.1 +- [fix] _agent_: Loading a profiler configuration from Teamscale was not possible if the potentially necessary proxy settings were not set yet. + +# 34.1.0 +- [feature] _agent_: New options `proxy-http(s)-host`/`-port`/`-user`/`-password` allow user to specify teamscale-specific proxy settings. +- [fix] _teamscale-maven-plugin_: NPE when no commit, nor revision was configured +- [fix] _teamscale-maven-plugin_: Fixed commit was ignored in commit resolution + +# 34.0.2 - [feature] _agent_: Prevent uploading coverage to the same project + revision or branch@timestamp when doing multi project upload via git.properties +- [fix] _impacted-test-engine_: Remove duplicated slashes in uniform paths and cluster ids for Cucumber Tests +- [fix] _impacted-test-engine_: Ensure that the config file specified via `java.util.logging.config.file` is used when specified # 34.0.1 - [fix] _agent_: Error was reported when the system under test used logback diff --git a/agent/README.md b/agent/README.md index e3aff930c..e02d56739 100644 --- a/agent/README.md +++ b/agent/README.md @@ -63,6 +63,7 @@ The following options are available: `logging` folder, e.g. to enable debug logging or log directly to the console. (For details see path format section below) - `proxy-password-file` (optional): path to a file that contains the password for a proxy server authentication. This file may only contain the password and nothing else. +- `proxy-` (optional): For other options regarding teamscale-specific proxy settings see [Teamscale-Specific-Proxy Section](#teamscale-specific-proxy-settings). - `mode` (optional): which coverage collection mode to use. Can be either `normal` or `testwise` (Default is `normal`) - `debug` (optional): `true`, `false` or a path to which the logs should be written to. `true` if no explicit value given. This option turns on debug mode. The logs will be written to console and the given file path. If no file path is given, @@ -87,6 +88,20 @@ Note that defining `-Djava.io.tmpdir` will change the temp directory that is bei If there is no log file at that location, it means the agent didn't even start and you have not configured it correctly. Check your applications console output for error messages. +#### Teamscale-Specific Proxy Settings +It is possible to specify teamscale-specific proxy settings that take prevalence over the system properties of the JVM. +If no or not all teamscale-specific proxy settings are provided the agent will fall back to the system properties of the +JVM. + +It is also possible to specify these options by prefixing the JVM flags for proxies with `teamscale.`, +for example `-Dteamscale.https.proxyHost`. + +The following options are available both for `https` and `http`: +- `proxy-https-host`/`proxy-http-host`: The host name of the proxy server. +- `proxy-https-port`/`proxy-http-port`: The port of the proxy server. +- `proxy-https-user`/`proxy-http-user`: The username for the proxy server. +- `proxy-https-password`/`proxy-http-password`: The password for the proxy user. + #### Testwise coverage If you want to collect testwise coverage, please have a look below in the [Testwise mode section](#testwise-coverage-modes). @@ -161,6 +176,7 @@ patterns with `*`, `**` and `?`. - the properties `git.branch` and `git.commit.time` (in the format `yyyy-MM-dd'T'HH:mm:ssZ` or `yyyy-MM-dd'T'HH:mm:ssXXX`) or - the properties `teamscale.commit.branch` and `teamscale.commit.time` (either as an epoch timestamp or in one of the two formats above) - `search-git-properties-recursively` Specifies whether to search for git.properties files recursively in folders or archive (jar, war, ear, aar) files. Default: true. +- `git-properties-commit-date-format` The Java data pattern `git.commit.time` is encoded with in `git.properties`. Defaults to `yyyy-MM-dd'T'HH:mm:ssZ`. - `teamscale-message` (optional): the commit message shown within Teamscale for the coverage upload (Default is "Agent coverage upload"). - `config-file` (optional): a file which contains one or more of the previously named options as `key=value` entries @@ -217,11 +233,6 @@ patterns with `*`, `**` and `?`. This can be used to encode e.g. a partition name that is parsed later on via Teamscale Artifactory connector options. - `artifactory-path-suffix` (optional): The path within the storage location between the default path and the uploaded artifact. -- `artifactory-git-properties-jar` (optional): Specify a Jar to search a `git.properties` file within. - If not specified, Git commit information is extracted from the first found `git.properties` file. - See `git-properties-jar` for details. -- `artifactory-git-properties-commit-date-format` (optional): - The Java data pattern `git.commit.time` is encoded with in `git.properties`. Defaults to `yyyy-MM-dd'T'HH:mm:ssZ`. ### The new standard upload schema ``` diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index a3b4d7fb6..3b59d9593 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -107,9 +107,10 @@ tasks.shadowDistZip { } tasks.processResources { + inputs.property("version", project.version) filesMatching("**/app.properties") { filter { - it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", appVersion) + it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", version.toString()) } } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java index d9876bc53..0c673ebac 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java @@ -1,9 +1,7 @@ package com.teamscale.jacoco.agent; -import com.teamscale.client.ProxySystemProperties; import com.teamscale.jacoco.agent.options.AgentOptions; import com.teamscale.jacoco.agent.logging.LoggingUtils; -import org.conqat.lib.commons.filesystem.FileSystemUtils; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; import org.eclipse.jetty.servlet.ServletContextHandler; @@ -14,9 +12,7 @@ import org.jacoco.agent.rt.RT; import org.slf4j.Logger; -import java.io.IOException; import java.lang.management.ManagementFactory; -import java.nio.file.Path; /** * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the @@ -40,7 +36,7 @@ public abstract class AgentBase { /** Constructor. */ public AgentBase(AgentOptions options) throws IllegalStateException { this.options = options; - setProxyPasswordFromFile(options.getProxyPasswordPath()); + try { controller = new JacocoRuntimeController(RT.getAgent()); } catch (IllegalStateException e) { @@ -60,22 +56,7 @@ public AgentBase(AgentOptions options) throws IllegalStateException { } } - /** Sets the proxy password JVM property from a file for both http and https. */ - private void setProxyPasswordFromFile(Path proxyPasswordFilePath) { - if (proxyPasswordFilePath == null) { - return; - } - try { - String proxyPassword = FileSystemUtils.readFileUTF8(proxyPasswordFilePath.toFile()).trim(); - new ProxySystemProperties(ProxySystemProperties.Protocol.HTTP).setProxyPassword(proxyPassword); - new ProxySystemProperties(ProxySystemProperties.Protocol.HTTPS).setProxyPassword(proxyPassword); - } catch (IOException e) { - logger.error( - "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.", - e); - } - } /** * Lazily generated string representation of the command line arguments to print to the log. @@ -133,10 +114,17 @@ private ServletContextHandler buildUsingResourceConfig() { */ void registerShutdownHook() { Runtime.getRuntime().addShutdownHook(new Thread(() -> { - stopServer(); - prepareShutdown(); - logger.info("CQSE JaCoCo agent successfully shut down."); - PreMain.closeLoggingResources(); + try { + logger.info("CQSE JaCoCo agent is shutting down..."); + stopServer(); + prepareShutdown(); + logger.info("CQSE JaCoCo agent successfully shut down."); + } catch (Exception e) { + logger.error("Exception during agent shutdown.", e); + } finally { + // Try to flush logging resources also in case of an exception during shutdown + PreMain.closeLoggingResources(); + } })); } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java b/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java index f9d7931ed..4ab473707 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java @@ -1,7 +1,6 @@ package com.teamscale.jacoco.agent; import com.teamscale.client.HttpUtils; -import com.teamscale.client.TeamscaleServer; import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException; import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender; import com.teamscale.jacoco.agent.options.AgentOptionParseException; @@ -13,15 +12,15 @@ import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils; import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleConfig; import com.teamscale.jacoco.agent.util.AgentUtils; import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner; import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner; import com.teamscale.jacoco.agent.logging.LoggingUtils; +import com.teamscale.report.util.ILogger; import org.conqat.lib.commons.collections.CollectionUtils; +import org.conqat.lib.commons.collections.Pair; import org.conqat.lib.commons.filesystem.FileSystemUtils; import org.conqat.lib.commons.string.StringUtils; -import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import java.io.File; @@ -47,8 +46,10 @@ public class PreMain { */ private static final String LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED"; - /** Environment variable from which to read the config ID to use. - * This is an ID for a profiler configuration that is stored in Teamscale. */ + /** + * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is + * stored in Teamscale. + */ private static final String CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID"; /** Environment variable from which to read the config file to use. */ @@ -71,9 +72,29 @@ public static void premain(String options, Instrumentation instrumentation) thro return; } - AgentOptions agentOptions; + AgentOptions agentOptions = null; try { - agentOptions = getAndApplyAgentOptions(options, environmentConfigId, environmentConfigFile); + Pair> parseResult = getAndApplyAgentOptions(options, environmentConfigId, + environmentConfigFile); + agentOptions = parseResult.getFirst(); + + // After parsing everything and configuring logging, we now + // can throw the caught exceptions. + for (Exception exception : parseResult.getSecond()) { + throw exception; + } + } catch (AgentOptionParseException e) { + getLoggerContext().getLogger(PreMain.class).error(e.getMessage(), e); + + // Flush logs to Teamscale, if configured. + closeLoggingResources(); + + // Unregister the profiler from Teamscale. + if (agentOptions != null && agentOptions.configurationViaTeamscale != null) { + agentOptions.configurationViaTeamscale.unregisterProfiler(); + } + + throw e; } catch (AgentOptionReceiveException e) { // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no // coverage is collected (see TS-33237) @@ -94,14 +115,14 @@ public static void premain(String options, Instrumentation instrumentation) thro agent.registerShutdownHook(); } - @NotNull - private static AgentOptions getAndApplyAgentOptions(String options, String environmentConfigId, - String environmentConfigFile) throws AgentOptionParseException, IOException, AgentOptionReceiveException { + private static Pair> getAndApplyAgentOptions(String options, String environmentConfigId, + String environmentConfigFile) throws AgentOptionParseException, IOException, AgentOptionReceiveException { DelayedLogger delayedLogger = new DelayedLogger(); List javaAgents = CollectionUtils.filter(ManagementFactory.getRuntimeMXBean().getInputArguments(), s -> s.contains("-javaagent")); - if (javaAgents.size() > 1) { + // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once + if (!javaAgents.stream().allMatch(javaAgent -> javaAgent.contains("teamscale-jacoco-agent.jar"))) { delayedLogger.warn("Using multiple java agents could interfere with coverage recording."); } if (!javaAgents.get(0).contains("teamscale-jacoco-agent.jar")) { @@ -112,9 +133,14 @@ private static AgentOptions getAndApplyAgentOptions(String options, String envir if (credentials == null) { delayedLogger.warn("Did not find a teamscale.properties file!"); } + + Pair> parseResult; AgentOptions agentOptions; try { - agentOptions = AgentOptionsParser.parse(options, environmentConfigId, environmentConfigFile, credentials, delayedLogger); + parseResult = AgentOptionsParser.parse( + options, environmentConfigId, environmentConfigFile, credentials, + delayedLogger); + agentOptions = parseResult.getFirst(); } catch (AgentOptionParseException e) { try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) { delayedLogger.errorAndStdErr("Failed to parse agent options: " + e.getMessage(), e); @@ -135,7 +161,8 @@ private static AgentOptions getAndApplyAgentOptions(String options, String envir Logger logger = LoggingUtils.getLogger(Agent.class); delayedLogger.logTo(logger); HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl()); - return agentOptions; + + return parseResult; } private static void attemptLogAndThrow(DelayedLogger delayedLogger) { @@ -156,7 +183,9 @@ private static void initializeLogging(AgentOptions agentOptions, DelayedLogger l } if (agentOptions.getTeamscaleServerOptions().isConfiguredForServerConnection()) { - LogToTeamscaleAppender.addTeamscaleAppenderTo(getLoggerContext(), agentOptions); + if (LogToTeamscaleAppender.addTeamscaleAppenderTo(getLoggerContext(), agentOptions)) { + logger.info("Logs are being forwarded to Teamscale at " + agentOptions.getTeamscaleServerOptions().url); + } } } @@ -170,7 +199,7 @@ static void closeLoggingResources() { * the HTTP server is used. */ private static AgentBase createAgent(AgentOptions agentOptions, - Instrumentation instrumentation) throws UploaderException, IOException { + Instrumentation instrumentation) throws UploaderException, IOException { if (agentOptions.useTestwiseCoverageMode()) { return TestwiseCoverageAgent.create(agentOptions); } else { @@ -198,7 +227,7 @@ private static void initializeDebugLogging(AgentOptions agentOptions, DelayedLog * this and falls back to the default logger. */ private static LoggingUtils.LoggingResources initializeFallbackLogging(String premainOptions, - DelayedLogger delayedLogger) { + DelayedLogger delayedLogger) { if (premainOptions == null) { return LoggingUtils.initializeDefaultLogging(); } @@ -231,7 +260,7 @@ private static LoggingUtils.LoggingResources initializeFallbackLogging(String pr /** Creates a fallback logger using the given config file. */ private static LoggingUtils.LoggingResources createFallbackLoggerFromConfig(String configLocation, - DelayedLogger delayedLogger) { + ILogger delayedLogger) { try { return LoggingUtils.initializeLogging( new FilePatternResolver(delayedLogger).parsePath(AgentOptionsParser.LOGGING_CONFIG_OPTION, diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java index 93760b454..b39d4ba1b 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocator.java @@ -4,11 +4,13 @@ import com.teamscale.jacoco.agent.upload.teamscale.DelayedTeamscaleMultiProjectUploader; import com.teamscale.jacoco.agent.util.DaemonThreadFactory; import com.teamscale.jacoco.agent.logging.LoggingUtils; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.VisibleForTesting; import org.slf4j.Logger; import java.io.File; import java.io.IOException; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -27,19 +29,22 @@ public class GitMultiProjectPropertiesLocator implements IGitPropertiesLocator { private final boolean recursiveSearch; - public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, boolean recursiveSearch) { + private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat; + + public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { // using a single threaded executor allows this class to be lock-free this(uploader, Executors .newSingleThreadExecutor( new DaemonThreadFactory(GitMultiProjectPropertiesLocator.class, - "git.properties Jar scanner thread")), recursiveSearch); + "git.properties Jar scanner thread")), recursiveSearch, gitPropertiesCommitTimeFormat); } public GitMultiProjectPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, Executor executor, - boolean recursiveSearch) { + boolean recursiveSearch, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { this.uploader = uploader; this.executor = executor; this.recursiveSearch = recursiveSearch; + this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat; } /** @@ -62,7 +67,7 @@ void searchFile(File file, boolean isJarFile) { List projectAndCommits = GitPropertiesLocatorUtils.getProjectRevisionsFromGitProperties( file, isJarFile, - recursiveSearch); + recursiveSearch, gitPropertiesCommitTimeFormat); if (projectAndCommits.isEmpty()) { logger.debug("No git.properties file found in {}", file); return; diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java index 8a3f02ac6..b0f0d6819 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitPropertiesLocatorUtils.java @@ -7,8 +7,8 @@ import com.teamscale.report.util.BashFileSkippingInputStream; import org.conqat.lib.commons.collections.Pair; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import javax.annotation.Nullable; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -85,9 +85,6 @@ public class GitPropertiesLocatorUtils { */ private static final String GIT_PROPERTIES_DEFAULT_GRADLE_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; - /** File ending of Java archive packages */ - public static final String JAR_FILE_ENDING = ".jar"; - /** * Reads the git SHA1 and branch and timestamp from the given jar file's git.properties and builds a commit * descriptor out of it. If no git.properties file can be found, returns null. @@ -95,16 +92,23 @@ public class GitPropertiesLocatorUtils { * @throws IOException If reading the jar file fails. * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. */ - public static List getCommitInfoFromGitProperties( - File file, boolean isJarFile, boolean recursiveSearch) throws IOException, InvalidGitPropertiesException { - List> entriesWithProperties = findGitPropertiesInFile(file, isJarFile, - recursiveSearch); + public static List getCommitInfoFromGitProperties(File file, boolean isJarFile, + boolean recursiveSearch, + @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) + throws IOException, InvalidGitPropertiesException { + List> entriesWithProperties = GitPropertiesLocatorUtils.findGitPropertiesInFile(file, + isJarFile, recursiveSearch); List result = new ArrayList<>(); + for (Pair entryWithProperties : entriesWithProperties) { - CommitInfo commitInfo = getCommitInfoFromGitProperties(entryWithProperties.getSecond(), - entryWithProperties.getFirst(), file, null); + String entry = entryWithProperties.getFirst(); + Properties properties = entryWithProperties.getSecond(); + + CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, entry, file, + gitPropertiesCommitTimeFormat); result.add(commitInfo); } + return result; } @@ -122,8 +126,7 @@ public static Pair extractGitPropertiesSearchRoot( switch (protocol) { case "file": File jarOrClassFolderFile = new File(jarOrClassFolderUrl.toURI()); - if (jarOrClassFolderFile.isDirectory() || org.conqat.lib.commons.string.StringUtils.endsWithOneOf( - jarOrClassFolderUrl.getPath().toLowerCase(), ".jar", ".war", ".ear", ".aar")) { + if (jarOrClassFolderFile.isDirectory() || isJarLikeFile(jarOrClassFolderUrl.getPath())) { return Pair.createPair(new File(jarOrClassFolderUrl.toURI()), !jarOrClassFolderFile.isDirectory()); } break; @@ -183,8 +186,7 @@ private static String extractArtefactUrl(URL jarOrClassFolderUrl) { String segment = pathSegments[segmentIdx]; artefactUrlBuilder.append(segment); artefactUrlBuilder.append("/"); - if (org.conqat.lib.commons.string.StringUtils.endsWithOneOf( - segment, ".jar", ".war", ".ear", ".aar")) { + if (isJarLikeFile(segment)) { break; } segmentIdx += 1; @@ -195,6 +197,11 @@ private static String extractArtefactUrl(URL jarOrClassFolderUrl) { return artefactUrlBuilder.toString(); } + private static boolean isJarLikeFile(String segment) { + return org.conqat.lib.commons.string.StringUtils.endsWithOneOf( + segment.toLowerCase(), ".jar", ".war", ".ear", ".aar"); + } + /** * Reads the 'teamscale.project' property values and the git SHA1s or branch + timestamp from all git.properties * files contained in the provided folder or archive file. @@ -203,13 +210,14 @@ private static String extractArtefactUrl(URL jarOrClassFolderUrl) { * @throws InvalidGitPropertiesException If a git.properties file is found but it is malformed. */ public static List getProjectRevisionsFromGitProperties( - File file, boolean isJarFile, boolean recursiveSearch) throws IOException, InvalidGitPropertiesException { + File file, boolean isJarFile, boolean recursiveSearch, + @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException { List> entriesWithProperties = findGitPropertiesInFile(file, isJarFile, recursiveSearch); List result = new ArrayList<>(); for (Pair entryWithProperties : entriesWithProperties) { CommitInfo commitInfo = getCommitInfoFromGitProperties(entryWithProperties.getSecond(), - entryWithProperties.getFirst(), file, null); + entryWithProperties.getFirst(), file, gitPropertiesCommitTimeFormat); String project = entryWithProperties.getSecond().getProperty(GIT_PROPERTIES_TEAMSCALE_PROJECT); if (commitInfo.isEmpty() && StringUtils.isEmpty(project)) { throw new InvalidGitPropertiesException( @@ -273,7 +281,7 @@ private static List> findGitPropertiesInNestedJarFiles( File directoryFile) throws IOException { List> result = new ArrayList<>(); List jarFiles = FileSystemUtils.listFilesRecursively(directoryFile, - file -> file.getName().endsWith(JAR_FILE_ENDING)); + file -> isJarLikeFile(file.getName())); for (File jarFile : jarFiles) { JarInputStream is = new JarInputStream(Files.newInputStream(jarFile.toPath())); String relativeFilePath = directoryFile.getName() + File.separator + directoryFile.toPath() @@ -323,7 +331,7 @@ static List> findGitPropertiesInArchive( Properties gitProperties = new Properties(); gitProperties.load(in); result.add(Pair.createPair(fullEntryName, gitProperties)); - } else if (entry.getName().endsWith(JAR_FILE_ENDING) && recursiveSearch) { + } else if (isJarLikeFile(entry.getName()) && recursiveSearch) { result.addAll(findGitPropertiesInArchive(new JarInputStream(in), fullEntryName, true)); } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java index 954cb5908..c78856aa1 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitSingleProjectPropertiesLocator.java @@ -3,10 +3,12 @@ import com.teamscale.jacoco.agent.upload.delay.DelayedUploader; import com.teamscale.jacoco.agent.util.DaemonThreadFactory; import com.teamscale.jacoco.agent.logging.LoggingUtils; +import org.checkerframework.checker.nullness.qual.Nullable; import org.slf4j.Logger; import java.io.File; import java.io.IOException; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -26,15 +28,17 @@ public class GitSingleProjectPropertiesLocator implements IGitPropertiesLocat private final DataExtractor dataExtractor; private final boolean recursiveSearch; + private final @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat; public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor, - boolean recursiveSearch) { + boolean recursiveSearch, + @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { // using a single threaded executor allows this class to be lock-free this(uploader, dataExtractor, Executors .newSingleThreadExecutor( new DaemonThreadFactory(GitSingleProjectPropertiesLocator.class, "git.properties Jar scanner thread")), - recursiveSearch); + recursiveSearch, gitPropertiesCommitTimeFormat); } /** @@ -42,12 +46,14 @@ public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtrac * of this class. */ public GitSingleProjectPropertiesLocator(DelayedUploader uploader, DataExtractor dataExtractor, - Executor executor, - boolean recursiveSearch) { + Executor executor, + boolean recursiveSearch, + @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) { this.uploader = uploader; this.dataExtractor = dataExtractor; this.executor = executor; this.recursiveSearch = recursiveSearch; + this.gitPropertiesCommitTimeFormat = gitPropertiesCommitTimeFormat; } /** @@ -61,7 +67,7 @@ public void searchFileForGitPropertiesAsync(File file, boolean isJarFile) { private void searchFile(File file, boolean isJarFile) { logger.debug("Searching jar file {} for a single git.properties", file); try { - List data = dataExtractor.extractData(file, isJarFile, recursiveSearch); + List data = dataExtractor.extractData(file, isJarFile, recursiveSearch, gitPropertiesCommitTimeFormat); if (data.isEmpty()) { logger.debug("No git.properties files found in {}", file.toString()); return; @@ -103,6 +109,7 @@ private void searchFile(File file, boolean isJarFile) { public interface DataExtractor { /** Extracts data from the JAR. */ List extractData(File file, boolean isJarFile, - boolean recursiveSearch) throws IOException, InvalidGitPropertiesException; + boolean recursiveSearch, + @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) throws IOException, InvalidGitPropertiesException; } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java index 48ee78655..3bccd6193 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java @@ -60,6 +60,10 @@ public static ConfigurationViaTeamscale retrieve(ILogger logger, String configur ProcessInformation processInformation = new ProcessInformationRetriever(logger).getProcessInformation(); Response response = teamscaleClient.registerProfiler(configurationId, processInformation).execute(); + if (response.code() == 405) { + response = teamscaleClient.registerProfilerLegacy(configurationId, + processInformation).execute(); + } if (!response.isSuccessful()) { if (response.code() >= 400 && response.code() < 500) { throw new AgentOptionParseException( @@ -109,6 +113,9 @@ public void startHeartbeatThreadAndRegisterShutdownHook() { private void sendHeartbeat() { try { Response response = teamscaleClient.sendHeartbeat(profilerId, profilerInfo).execute(); + if (response.code() == 405) { + response = teamscaleClient.sendHeartbeatLegacy(profilerId, profilerInfo).execute(); + } if (!response.isSuccessful()) { LoggingUtils.getLogger(this) .error("Failed to send heartbeat. Teamscale responded with: " + response.errorBody().string()); @@ -118,9 +125,13 @@ private void sendHeartbeat() { } } - private void unregisterProfiler() { + /** Unregisters the profiler in Teamscale (marks it as shut down). */ + public void unregisterProfiler() { try { Response response = teamscaleClient.unregisterProfiler(profilerId).execute(); + if (response.code() == 405) { + response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute(); + } if (!response.isSuccessful()) { LoggingUtils.getLogger(this) .error("Failed to unregister profiler. Teamscale responded with: " + response.errorBody() diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java index 0d6ae3b84..fe41cb5a1 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java @@ -4,25 +4,60 @@ import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.status.ErrorStatus; +import com.teamscale.client.ITeamscaleService; import com.teamscale.client.ProfilerLogEntry; import com.teamscale.client.TeamscaleClient; import com.teamscale.jacoco.agent.options.AgentOptions; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.conqat.lib.commons.collections.IdentityHashSet; import retrofit2.Call; +import java.net.ConnectException; import java.time.Duration; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; -import java.util.concurrent.*; - +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static com.teamscale.jacoco.agent.logging.LoggingUtils.getStackTraceFromEvent; + +/** + * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection + * issues and sends them later. + */ public class LogToTeamscaleAppender extends AppenderBase { + /** Flush the logs after N elements are in the queue */ + private static final int BATCH_SIZE = 50; + + /** Flush the logs in the given time interval */ + private static final Duration FLUSH_INTERVAL = Duration.ofSeconds(3); + + /** The unique ID of the profiler */ private String profilerId; - private TeamscaleClient teamscaleClient; - private int batchSize = 10; - private Duration flushInterval = Duration.ofSeconds(3); - private final List logBuffer = new ArrayList<>(); + + /** The service client for sending logs to Teamscale */ + private static ITeamscaleService teamscaleClient; + + /** Buffer for unsent logs. We use a set here to allow for removing + * entries fast after sending them to Teamscale was successful. */ + private final LinkedHashSet logBuffer = new LinkedHashSet<>(); + + /** Scheduler for sending logs after the configured time interval */ private final ScheduledExecutorService scheduler; + /** Active log flushing threads */ + private final Set> activeLogFlushes = new IdentityHashSet<>(); + + /** Is there a flush going on right now? */ + private final AtomicBoolean isFlusing = new AtomicBoolean(false); + public LogToTeamscaleAppender() { this.scheduler = Executors.newScheduledThreadPool(1, r -> { // Make the thread a daemon so that it does not prevent the JVM from terminating. @@ -32,73 +67,90 @@ public LogToTeamscaleAppender() { }); } - public void setTeamscaleClient(TeamscaleClient teamscaleClient) { - this.teamscaleClient = teamscaleClient; - } - - public void setProfilerId(String profilerId) { - this.profilerId = profilerId; - } - - public void setBatchSize(int batchSize) { - this.batchSize = batchSize; - } - - public void setFlushInterval(Duration flushInterval) { - this.flushInterval = flushInterval; - } - @Override public void start() { super.start(); - scheduler.scheduleAtFixedRate(this::flush, flushInterval.toMillis(), flushInterval.toMillis(), TimeUnit.MILLISECONDS); + scheduler.scheduleAtFixedRate(() -> { + synchronized (activeLogFlushes) { + activeLogFlushes.removeIf(CompletableFuture::isDone); + if (this.activeLogFlushes.isEmpty()) { + flush(); + } + } + }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); } @Override protected void append(ILoggingEvent eventObject) { synchronized (logBuffer) { logBuffer.add(formatLog(eventObject)); - if (logBuffer.size() >= batchSize) { + if (logBuffer.size() >= BATCH_SIZE) { flush(); } } } private ProfilerLogEntry formatLog(ILoggingEvent eventObject) { + String trace = getStackTraceFromEvent(eventObject); long timestamp = eventObject.getTimeStamp(); String message = eventObject.getFormattedMessage(); String severity = eventObject.getLevel().toString(); - return new ProfilerLogEntry(timestamp, message, severity); + return new ProfilerLogEntry(timestamp, message, trace, severity); } private void flush() { - List logsToSend; - synchronized (logBuffer) { - if (logBuffer.isEmpty()) { - return; - } - logsToSend = new ArrayList<>(logBuffer); - logBuffer.clear(); - } - sendLogs(logsToSend); + sendLogs(); } - private void sendLogs(List logs) { - CompletableFuture.runAsync(() -> { - try { - Call call = teamscaleClient.service.postProfilerLog(profilerId, logs); - retrofit2.Response response = call.execute(); - if (!response.isSuccessful()) { - throw new RuntimeException("Failed to send log: HTTP error code : " + response.code()); + /** Send logs in a separate thread */ + private void sendLogs() { + synchronized (activeLogFlushes) { + activeLogFlushes.add(CompletableFuture.runAsync(() -> { + if (isFlusing.compareAndSet(false, true)) { + try { + if (teamscaleClient == null) { + // There might be no connection configured. + return; + } + + List logsToSend; + synchronized (logBuffer) { + logsToSend = new ArrayList<>(logBuffer); + } + + Call call = teamscaleClient.postProfilerLog(profilerId, logsToSend); + retrofit2.Response response = call.execute(); + if (!response.isSuccessful()) { + throw new IllegalStateException("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. + logsToSend.forEach(logBuffer::remove); + } + } catch (Exception e) { + // We do not report on exceptions here. + if (!(e instanceof ConnectException)) { + addStatus(new ErrorStatus("Sending logs to Teamscale failed: " + e.getMessage(), this, e)); + } + } finally { + isFlusing.set(false); + } } - } catch (Exception e) { - e.printStackTrace(); // Handle exceptions appropriately in production code - } - }); + }).whenComplete((result, throwable) -> { + synchronized (activeLogFlushes) { + activeLogFlushes.removeIf(CompletableFuture::isDone); + } + })); + } } @Override public void stop() { + // Already flush here once to make sure that we do not miss too much. + flush(); + scheduler.shutdown(); try { if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { @@ -107,20 +159,46 @@ public void stop() { } catch (InterruptedException e) { scheduler.shutdownNow(); } - flush(); // Ensure remaining logs are sent + + // A final flush after the scheduler has been shut down. + flush(); + + // Block until all flushes are done + CompletableFuture.allOf(activeLogFlushes.toArray(new CompletableFuture[0])).join(); + super.stop(); } + public void setTeamscaleClient(ITeamscaleService teamscaleClient) { + this.teamscaleClient = teamscaleClient; + } + + public void setProfilerId(String profilerId) { + this.profilerId = profilerId; + } + + /** + * Add the {@link com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender} to the logging configuration + * and enable/start it. + */ + public static boolean addTeamscaleAppenderTo(LoggerContext context, AgentOptions agentOptions) { + @Nullable TeamscaleClient client = agentOptions.createTeamscaleClient( + false); + if (client == null || agentOptions.configurationViaTeamscale == null) { + return false; + } - public static void addTeamscaleAppenderTo(LoggerContext context, AgentOptions agentOptions) { + ITeamscaleService serviceClient = client.getService(); LogToTeamscaleAppender logToTeamscaleAppender = new LogToTeamscaleAppender(); logToTeamscaleAppender.setContext(context); logToTeamscaleAppender.setProfilerId(agentOptions.configurationViaTeamscale.getProfilerId()); - logToTeamscaleAppender.setTeamscaleClient(agentOptions.createTeamscaleClient()); + logToTeamscaleAppender.setTeamscaleClient(serviceClient); logToTeamscaleAppender.start(); Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME); rootLogger.addAppender(logToTeamscaleAppender); + + return true; } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java index 5f90300e0..6e0bcd87b 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java @@ -7,6 +7,10 @@ import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.joran.JoranConfigurator; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; import ch.qos.logback.core.joran.spi.JoranException; import ch.qos.logback.core.util.StatusPrinter; import com.teamscale.jacoco.agent.Agent; @@ -59,6 +63,23 @@ public static LoggerContext getLoggerContext() { return (LoggerContext) LoggerFactory.getILoggerFactory(); } + /** + * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. + * + * @param event the logging event containing the exception + * @return the stack trace as a String, or null if no exception is associated + */ + public static String getStackTraceFromEvent(ILoggingEvent event) { + IThrowableProxy throwableProxy = event.getThrowableProxy(); + + if (throwableProxy != null) { + // Use ThrowableProxyUtil to convert the IThrowableProxy to a String + return ThrowableProxyUtil.asString(throwableProxy); + } + + return null; + } + /** * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. https://logback.qos.ch/manual/configuration.html diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java index f2855083b..f0d6337d4 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java @@ -7,6 +7,7 @@ import com.teamscale.client.EReportFormat; import com.teamscale.client.FileSystemUtils; +import com.teamscale.client.ProxySystemProperties; import com.teamscale.client.StringUtils; import com.teamscale.client.TeamscaleClient; import com.teamscale.client.TeamscaleServer; @@ -35,6 +36,7 @@ import com.teamscale.report.EDuplicateClassFileBehavior; import com.teamscale.report.util.ClasspathWildcardIncludeFilter; import com.teamscale.report.util.ILogger; +import org.checkerframework.checker.nullness.qual.Nullable; import org.conqat.lib.commons.assertion.CCSMAssert; import org.conqat.lib.commons.collections.PairList; import org.jacoco.core.runtime.WildcardMatcher; @@ -78,9 +80,19 @@ public class AgentOptions { /** See {@link AgentOptions#GIT_PROPERTIES_JAR_OPTION} */ /* package */ File gitPropertiesJar; + /** + * Related to {@link AgentOptions#GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION} + */ + public DateTimeFormatter gitPropertiesCommitTimeFormat = null; + /** Option name that allows to specify a jar file that contains the git commit hash in a git.properties file. */ public static final String GIT_PROPERTIES_JAR_OPTION = "git-properties-jar"; + /** + * Specifies the date format in which the commit timestamp in the git.properties file is formatted. + */ + public static final String GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION = "git-properties-commit-date-format"; + /** * The original options passed to the agent. */ @@ -107,10 +119,13 @@ public class AgentOptions { * The directory to write the XML traces to. */ private Path outputDirectory; - /** - * A path to the file that contains the password for the proxy authentication. - */ - /* package */ Path proxyPasswordPath; + + /** Contains the options related to teamscale-specific proxy settings for http. */ + /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttp; + + /** Contains the options related to teamscale-specific proxy settings for https. */ + /* package */ TeamscaleProxyOptions teamscaleProxyOptionsForHttps; + /** * Additional metadata files to upload together with the coverage XML. */ @@ -210,6 +225,10 @@ public class AgentOptions { public AgentOptions(ILogger logger) { this.logger = logger; setParentOutputDirectory(AgentUtils.getMainTempDirectory().resolve("coverage")); + teamscaleProxyOptionsForHttp = new TeamscaleProxyOptions( + ProxySystemProperties.Protocol.HTTP, logger); + teamscaleProxyOptionsForHttps = new TeamscaleProxyOptions( + ProxySystemProperties.Protocol.HTTPS, logger); } /** @see #debugLogging */ @@ -229,10 +248,6 @@ public String getOriginalOptionsString() { return originalOptionsString; } - public Path getProxyPasswordPath() { - return proxyPasswordPath; - } - /** * Remove parts of the API key for security reasons from the options string. String is used for logging purposes. *

@@ -378,8 +393,9 @@ private void validateTestwiseCoverageConfig(Validator validator) { * Creates a {@link TeamscaleClient} based on the agent options. Returns null if the user did not fully configure a * Teamscale connection. */ - public TeamscaleClient createTeamscaleClient() { - if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload()) { + public @Nullable TeamscaleClient createTeamscaleClient(boolean requireSingleProjectUploadConfig) { + if (teamscaleServer.isConfiguredForSingleProjectTeamscaleUpload() || + !requireSingleProjectUploadConfig && teamscaleServer.isConfiguredForServerConnection()) { return new TeamscaleClient(teamscaleServer.url.toString(), teamscaleServer.userName, teamscaleServer.userAccessToken, teamscaleServer.project); } @@ -451,7 +467,14 @@ public IUploader createUploader(Instrumentation instrumentation) throws Uploader } @NotNull - private IUploader createArtifactoryUploader(Instrumentation instrumentation) { + private IUploader createArtifactoryUploader(Instrumentation instrumentation) throws UploaderException { + if (gitPropertiesJar != null) { + logger.info("You did not provide a commit to upload to directly, so the Agent will try to" + + "auto-detect it by searching the provided " + GIT_PROPERTIES_JAR_OPTION + " at " + + gitPropertiesJar.getAbsolutePath() + " for a git.properties file."); + artifactoryConfig.commitInfo = ArtifactoryConfig.parseGitProperties(gitPropertiesJar, + this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); + } if (!artifactoryConfig.hasCommitInfo()) { logger.info("You did not provide a commit to upload to directly, so the Agent will try and" + " auto-detect it by searching all profiled Jar/War/Ear/... files for a git.properties file."); @@ -514,14 +537,16 @@ private DelayedTeamscaleMultiProjectUploader createTeamscaleMultiProjectUploader private void startGitPropertiesSearchInJarFile(DelayedUploader uploader, File gitPropertiesJar) { GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader, - GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively); + GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively, + this.gitPropertiesCommitTimeFormat); locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true); } private void registerSingleGitPropertiesLocator(DelayedUploader uploader, Instrumentation instrumentation) { GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(uploader, - GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively); + GitPropertiesLocatorUtils::getProjectRevisionsFromGitProperties, this.searchGitPropertiesRecursively, + this.gitPropertiesCommitTimeFormat); instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); } @@ -547,14 +572,14 @@ private DelayedUploader createDelayedSingleProjectTeamscaleUpl private void startMultiGitPropertiesFileSearchInJarFile(DelayedTeamscaleMultiProjectUploader uploader, File gitPropertiesJar) { GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader, - this.searchGitPropertiesRecursively); + this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); locator.searchFileForGitPropertiesAsync(gitPropertiesJar, true); } private void registerMultiGitPropertiesLocator(DelayedTeamscaleMultiProjectUploader uploader, Instrumentation instrumentation) { GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator(uploader, - this.searchGitPropertiesRecursively); + this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); } @@ -567,9 +592,8 @@ private IUploader createDelayedArtifactoryUploader(Instrumentation instrumentati }, outputDirectory); GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>( uploader, - (file, isJarFile, recursiveSearch) -> ArtifactoryConfig.parseGitProperties( - file, isJarFile, artifactoryConfig.gitPropertiesCommitTimeFormat, recursiveSearch), - this.searchGitPropertiesRecursively); + GitPropertiesLocatorUtils::getCommitInfoFromGitProperties, + this.searchGitPropertiesRecursively, this.gitPropertiesCommitTimeFormat); instrumentation.addTransformer(new GitPropertiesLocatingTransformer(locator, getLocationIncludeFilter())); return uploader; } @@ -724,4 +748,12 @@ public ETestwiseCoverageMode getTestwiseCoverageMode() { public boolean shouldIgnoreUncoveredClasses() { return ignoreUncoveredClasses; } + + /** @return the {@link TeamscaleProxyOptions} for the given protocol. */ + public TeamscaleProxyOptions getTeamscaleProxyOptions(ProxySystemProperties.Protocol protocol) { + if (protocol == ProxySystemProperties.Protocol.HTTP) { + return teamscaleProxyOptionsForHttp; + } + return teamscaleProxyOptionsForHttps; + } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java index a91a626ad..c28ed354e 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java @@ -6,7 +6,9 @@ package com.teamscale.jacoco.agent.options; import com.google.common.annotations.VisibleForTesting; +import com.teamscale.client.ProxySystemProperties; import com.teamscale.client.StringUtils; +import com.teamscale.client.TeamscaleProxySystemProperties; import com.teamscale.jacoco.agent.commandline.Validator; import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException; import com.teamscale.jacoco.agent.configuration.ConfigurationViaTeamscale; @@ -17,6 +19,7 @@ import com.teamscale.report.EDuplicateClassFileBehavior; import com.teamscale.report.util.ILogger; import okhttp3.HttpUrl; +import org.apache.commons.compress.utils.Lists; import org.conqat.lib.commons.collections.CollectionUtils; import org.conqat.lib.commons.collections.Pair; import org.conqat.lib.commons.filesystem.FileSystemUtils; @@ -25,10 +28,16 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.nio.file.InvalidPathException; +import java.nio.file.Path; import java.nio.file.Paths; +import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.Collection; import java.util.List; +import java.util.Map; +import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION; +import static com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig.ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION; import static java.util.stream.Collectors.joining; /** @@ -51,6 +60,7 @@ public class AgentOptionsParser { private final String environmentConfigId; private final String environmentConfigFile; private final TeamscaleCredentials credentials; + private final List collectedErrors; /** * Parses the given command-line options. @@ -58,22 +68,38 @@ public class AgentOptionsParser { * @param environmentConfigId The Profiler configuration ID given via an environment variable. * @param environmentConfigFile The Profiler configuration file given via an environment variable. */ - public static AgentOptions parse(String optionsString, String environmentConfigId, String environmentConfigFile, - TeamscaleCredentials credentials, - ILogger logger) throws AgentOptionParseException, AgentOptionReceiveException { - return new AgentOptionsParser(logger, environmentConfigId, environmentConfigFile, credentials).parse( - optionsString); + public static Pair> parse(String optionsString, String environmentConfigId, String environmentConfigFile, + TeamscaleCredentials credentials, + ILogger logger) throws AgentOptionParseException, AgentOptionReceiveException { + AgentOptionsParser parser = new AgentOptionsParser(logger, environmentConfigId, environmentConfigFile, credentials); + AgentOptions options = parser.parse(optionsString); + return Pair.createPair(options, parser.getCollectedErrors()); } @VisibleForTesting AgentOptionsParser(ILogger logger, String environmentConfigId, String environmentConfigFile, - TeamscaleCredentials credentials) { + TeamscaleCredentials credentials) { this.logger = logger; this.filePatternResolver = new FilePatternResolver(logger); this.teamscaleConfig = new TeamscaleConfig(logger, filePatternResolver); this.environmentConfigId = environmentConfigId; this.environmentConfigFile = environmentConfigFile; this.credentials = credentials; + this.collectedErrors = Lists.newArrayList(); + } + + private List getCollectedErrors() { + return collectedErrors; + } + + /** + * Throw the first collected exception, if present. + */ + @VisibleForTesting + public void throwOnCollectedErrors() throws Exception { + for (Exception e : collectedErrors) { + throw e; + } } /** @@ -81,10 +107,12 @@ public static AgentOptions parse(String optionsString, String environmentConfigI */ /* package */ AgentOptions parse( String optionsString) throws AgentOptionParseException, AgentOptionReceiveException { + if (optionsString == null) { optionsString = ""; } logger.debug("Parsing options: " + optionsString); + AgentOptions options = new AgentOptions(logger); options.originalOptionsString = optionsString; @@ -97,27 +125,58 @@ public static AgentOptions parse(String optionsString, String environmentConfigI if (!StringUtils.isEmpty(optionsString)) { String[] optionParts = optionsString.split(","); for (String optionPart : optionParts) { - handleOption(options, optionPart); + try { + handleOptionPart(options, optionPart); + } catch (Exception e) { + collectedErrors.add(e); + } } } - handleConfigFromEnvironment(options); + // we have to put the proxy options into system properties before reading the configuration from Teamscale as we + // might need them to connect to Teamscale + putTeamscaleProxyOptionsIntoSystemProperties(options); + + handleConfigId(options); + handleConfigFile(options); Validator validator = options.getValidator(); if (!validator.isValid()) { - throw new AgentOptionParseException("Invalid options given: " + validator.getErrorMessage()); + collectedErrors.add(new AgentOptionParseException("Invalid options given: " + validator.getErrorMessage())); } + return options; } - private void handleConfigFromEnvironment( - AgentOptions options) throws AgentOptionParseException, AgentOptionReceiveException { + /** + * Stores the agent options for proxies in the {@link TeamscaleProxySystemProperties} and overwrites the password + * with the password found in the proxy-password-file if necessary. + */ + @VisibleForTesting + public static void putTeamscaleProxyOptionsIntoSystemProperties(AgentOptions options) { + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) + .putTeamscaleProxyOptionsIntoSystemProperties(); + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) + .putTeamscaleProxyOptionsIntoSystemProperties(); + } + + private void handleConfigId(AgentOptions options) throws AgentOptionReceiveException, AgentOptionParseException { if (environmentConfigId != null) { - handleOption(options, "config-id=" + environmentConfigId); + if (options.teamscaleServer.configId != null) { + logger.warn( + "You specified an ID for a profiler configuration in Teamscale both in the agent options and using an environment variable." + + " The environment variable will override the ID specified using the agent options." + + " Please use one or the other."); + } + handleOptionPart(options, "config-id=" + environmentConfigId); } + readConfigFromTeamscale(options); + } + + private void handleConfigFile(AgentOptions options) throws AgentOptionParseException, AgentOptionReceiveException { if (environmentConfigFile != null) { - handleOption(options, "config-file=" + environmentConfigFile); + handleOptionPart(options, "config-file=" + environmentConfigFile); } if (environmentConfigId != null && environmentConfigFile != null) { @@ -130,11 +189,17 @@ private void handleConfigFromEnvironment( /** * Parses and stores the given option in the format key=value. */ - private void handleOption(AgentOptions options, - String optionPart) throws AgentOptionParseException, AgentOptionReceiveException { + private void handleOptionPart(AgentOptions options, + String optionPart) throws AgentOptionParseException, AgentOptionReceiveException { Pair keyAndValue = parseOption(optionPart); - String key = keyAndValue.getFirst(); - String value = keyAndValue.getSecond(); + handleOption(options, keyAndValue.getFirst(), keyAndValue.getSecond()); + } + + /** + * Parses and stores the option with the given key and value. + */ + private void handleOption(AgentOptions options, + String key, String value) throws AgentOptionParseException, AgentOptionReceiveException { if (key.startsWith("debug")) { handleDebugOption(options, value); return; @@ -148,7 +213,7 @@ private void handleOption(AgentOptions options, return; } if (key.startsWith("artifactory-") && ArtifactoryConfig - .handleArtifactoryOptions(options.artifactoryConfig, filePatternResolver, key, value)) { + .handleArtifactoryOptions(options.artifactoryConfig, key, value)) { return; } if (key.startsWith("azure-") && AzureFileStorageConfig @@ -156,12 +221,45 @@ private void handleOption(AgentOptions options, value)) { return; } + if (key.startsWith("proxy-") && handleProxyOptions(options, StringUtils.stripPrefix(key, "proxy-"), value, + filePatternResolver)) { + return; + } if (handleAgentOptions(options, key, value)) { return; } throw new AgentOptionParseException("Unknown option: " + key); } + private boolean handleProxyOptions(AgentOptions options, String key, String value, + FilePatternResolver filePatternResolver) throws AgentOptionParseException { + String httpsPrefix = ProxySystemProperties.Protocol.HTTPS + "-"; + if (key.startsWith(httpsPrefix) + && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) + .handleTeamscaleProxyOptions(StringUtils.stripPrefix( + key, httpsPrefix), value)) { + return true; + } + + String httpPrefix = ProxySystemProperties.Protocol.HTTP + "-"; + if (key.startsWith(httpPrefix) + && options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) + .handleTeamscaleProxyOptions(StringUtils.stripPrefix( + key, httpPrefix), value)) { + return true; + } + + if (key.equals("password-file")) { + Path proxyPasswordPath = filePatternResolver.parsePath(key, value); + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTPS) + .setProxyPasswordPath(proxyPasswordPath); + options.getTeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP) + .setProxyPasswordPath(proxyPasswordPath); + return true; + } + return false; + } + /** Parses and stores the debug logging file path if given. */ private void handleDebugOption(AgentOptions options, String value) { if (value.equalsIgnoreCase("false")) { @@ -199,7 +297,7 @@ private boolean handleAgentOptions(AgentOptions options, String key, String valu throws AgentOptionParseException, AgentOptionReceiveException { switch (key) { case "config-id": - readConfigFromTeamscale(options, value); + storeConfigId(options, value); return true; case CONFIG_FILE_OPTION: readConfigFromFile(options, filePatternResolver.parsePath(key, value).toFile()); @@ -207,9 +305,6 @@ private boolean handleAgentOptions(AgentOptions options, String key, String valu case LOGGING_CONFIG_OPTION: options.loggingConfig = filePatternResolver.parsePath(key, value); return true; - case "proxy-password-file": - options.proxyPasswordPath = filePatternResolver.parsePath(key, value); - return true; case "interval": options.dumpIntervalInMinutes = parseInt(key, value); return true; @@ -241,9 +336,22 @@ private boolean handleAgentOptions(AgentOptions options, String key, String valu case "search-git-properties-recursively": options.searchGitPropertiesRecursively = Boolean.parseBoolean(value); return true; + case ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION: + logger.warn( + "The option " + ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION + " is deprecated. It still has an effect, " + + "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_JAR_OPTION + "."); + // intended fallthrough (acts as alias) case AgentOptions.GIT_PROPERTIES_JAR_OPTION: options.gitPropertiesJar = getGitPropertiesJarFile(value); return true; + case ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: + logger.warn( + "The option " + ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + " is deprecated. It still has an effect, " + + "but should be replaced with the equivalent option " + AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION + "."); + // intended fallthrough (acts as alias) + case AgentOptions.GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: + options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value); + return true; case "mode": options.mode = parseEnumValue(key, value, EMode.class); return true; @@ -273,14 +381,22 @@ private boolean handleAgentOptions(AgentOptions options, String key, String valu } } - private void readConfigFromTeamscale(AgentOptions options, - String configId) throws AgentOptionParseException, AgentOptionReceiveException { + private void storeConfigId(AgentOptions options, String configId) throws AgentOptionParseException { if (!options.teamscaleServer.isConfiguredForServerConnection()) { throw new AgentOptionParseException( "Has specified config-id '" + configId + "' without teamscale url/user/accessKey! The options need to be defined in teamscale.properties."); } options.teamscaleServer.configId = configId; - ConfigurationViaTeamscale configuration = ConfigurationViaTeamscale.retrieve(logger, configId, + } + + private void readConfigFromTeamscale( + AgentOptions options) throws AgentOptionParseException, AgentOptionReceiveException { + if (options.teamscaleServer.configId == null) { + return; + } + + ConfigurationViaTeamscale configuration = ConfigurationViaTeamscale.retrieve(logger, + options.teamscaleServer.configId, options.teamscaleServer.url, options.teamscaleServer.userName, options.teamscaleServer.userAccessToken); @@ -325,7 +441,7 @@ public static > T parseEnumValue(String key, String value, Cla * excludes=third.party.* */ private void readConfigFromFile(AgentOptions options, - File configFile) throws AgentOptionParseException, AgentOptionReceiveException { + File configFile) throws AgentOptionParseException, AgentOptionReceiveException { try { String content = FileSystemUtils.readFileUTF8(configFile); readConfigFromString(options, content); @@ -339,15 +455,19 @@ private void readConfigFromFile(AgentOptions options, } private void readConfigFromString(AgentOptions options, - String content) throws AgentOptionParseException, AgentOptionReceiveException { + String content) throws AgentOptionParseException, AgentOptionReceiveException { List configFileKeyValues = org.conqat.lib.commons.string.StringUtils.splitLinesAsList( content); for (String optionKeyValue : configFileKeyValues) { - String trimmedOption = optionKeyValue.trim(); - if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) { - continue; + try { + String trimmedOption = optionKeyValue.trim(); + if (trimmedOption.isEmpty() || trimmedOption.startsWith(COMMENT_PREFIX)) { + continue; + } + handleOptionPart(options, optionKeyValue); + } catch (Exception e) { + collectedErrors.add(e); } - handleOption(options, optionKeyValue); } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java new file mode 100644 index 000000000..96a01d999 --- /dev/null +++ b/agent/src/main/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptions.java @@ -0,0 +1,120 @@ +package com.teamscale.jacoco.agent.options; + +import com.teamscale.client.ProxySystemProperties; +import com.teamscale.client.StringUtils; +import com.teamscale.client.TeamscaleProxySystemProperties; +import com.teamscale.report.util.ILogger; +import org.conqat.lib.commons.filesystem.FileSystemUtils; + +import java.io.IOException; +import java.nio.file.Path; + +/** + * Parses agent command line options related to the proxy settings. + */ +public class TeamscaleProxyOptions { + + private final ILogger logger; + + /** The host of the proxy server. */ + /* package */ String proxyHost; + + /** The port of the proxy server. */ + /* package */ int proxyPort; + + /** The password for the proxy user. */ + /* package */ String proxyPassword; + + public void setProxyPasswordPath(Path proxyPasswordPath) { + this.proxyPasswordPath = proxyPasswordPath; + } + + /** A path to the file that contains the password for the proxy authentication. */ + /* package */ Path proxyPasswordPath; + + /** The username of the proxy user. */ + /* package */ String proxyUser; + + private final ProxySystemProperties.Protocol protocol; + + /** Constructor. */ + public TeamscaleProxyOptions(ProxySystemProperties.Protocol protocol, ILogger logger) { + this.protocol = protocol; + this.logger = logger; + ProxySystemProperties proxySystemProperties = new ProxySystemProperties(protocol); + proxyHost = proxySystemProperties.getProxyHost(); + try { + proxyPort = proxySystemProperties.getProxyPort(); + } catch (ProxySystemProperties.IncorrectPortFormatException e) { + proxyPort = -1; + logger.warn(e.getMessage()); + } + proxyUser = proxySystemProperties.getProxyUser(); + proxyPassword = proxySystemProperties.getProxyPassword(); + } + + /** + * Processes the command-line options for proxies. + * + * @return true if it has successfully processed the given option. + */ + public boolean handleTeamscaleProxyOptions(String key, String value) throws AgentOptionParseException { + if ("host".equals(key)){ + proxyHost = value; + return true; + } + String proxyPortOption = "port"; + if (proxyPortOption.equals(key)) { + try { + proxyPort = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new AgentOptionParseException("Could not parse proxy port \"" + value + + "\" set via \"" + proxyPortOption + "\"", e); + } + return true; + } + if ("user".equals(key)) { + proxyUser = value; + return true; + } + if ("password".equals(key)) { + proxyPassword = value; + return true; + } + return false; + } + + /** Stores the teamscale-specific proxy settings as system properties to make them always available. */ + public void putTeamscaleProxyOptionsIntoSystemProperties() { + TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol); + if (!StringUtils.isEmpty(proxyHost)) { + teamscaleProxySystemProperties.setProxyHost(proxyHost); + } + if (proxyPort > 0) { + teamscaleProxySystemProperties.setProxyPort(proxyPort); + } + if(!StringUtils.isEmpty(proxyUser)) { + teamscaleProxySystemProperties.setProxyUser(proxyUser); + } + if(!StringUtils.isEmpty(proxyPassword)) { + teamscaleProxySystemProperties.setProxyPassword(proxyPassword); + } + + setProxyPasswordFromFile(proxyPasswordPath); + } + + /** Sets the proxy password JVM property from a file for the protocol in this instance of {@link TeamscaleProxyOptions}. */ + private void setProxyPasswordFromFile(Path proxyPasswordFilePath) { + if (proxyPasswordFilePath == null) { + return; + } + try { + String proxyPassword = FileSystemUtils.readFileUTF8(proxyPasswordFilePath.toFile()).trim(); + new TeamscaleProxySystemProperties(protocol).setProxyPassword(proxyPassword); + } catch (IOException e) { + logger.error( + "Unable to open file containing proxy password. Please make sure the file exists and the user has the permissions to read the file.", + e); + } + } +} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java index 328d1c217..1c80b3259 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageToJsonStrategyBase.java @@ -121,7 +121,7 @@ private String createTestwiseCoverageReport(boolean partial) throws IOException, if (execution == null) { return null; } else { - return execution.getUniformPath(); + return execution.uniformPath; } }).collect(toList()); @@ -132,10 +132,9 @@ private String createTestwiseCoverageReport(boolean partial) throws IOException, reportGenerator.updateClassDirCache(); TestwiseCoverage testwiseCoverage = reportGenerator.convert(testExecFile); logger.debug("Created testwise coverage report (containing coverage for tests `{}`)", - testwiseCoverage.getTests().stream().map(TestCoverageBuilder::getUniformPath).collect(toList())); + testwiseCoverage.getTests().values().stream().map(TestCoverageBuilder::getUniformPath).collect(toList())); - TestwiseCoverageReport report = TestwiseCoverageReportBuilder - .createFrom(availableTests, testwiseCoverage.getTests(), testExecutions, partial); + TestwiseCoverageReport report = TestwiseCoverageReportBuilder.createFrom(availableTests, testwiseCoverage.getTests().values(), testExecutions, partial); testExecFile.delete(); testExecFile = null; diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java index 40bfeab05..840e40128 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/CoverageViaHttpStrategy.java @@ -11,6 +11,8 @@ import com.teamscale.report.testwise.model.builder.TestInfoBuilder; import org.slf4j.Logger; +import java.util.Objects; + /** * Strategy which directly converts the collected coverage into a JSON object in place and returns the result to the * caller as response to the http request. If a test execution is given it is merged into the representation and @@ -36,12 +38,12 @@ public TestInfo testEnd(String test, TestExecution testExecution) TestInfoBuilder builder = new TestInfoBuilder(test); Dump dump = controller.dumpAndReset(); reportGenerator.updateClassDirCache(); - builder.setCoverage(reportGenerator.convert(dump)); + builder.setCoverage(Objects.requireNonNull(reportGenerator.convert(dump))); if (testExecution != null) { builder.setExecution(testExecution); } TestInfo testInfo = builder.build(); - logger.debug("Generated test info {}", testInfo.toString()); + logger.debug("Generated test info {}", testInfo); return testInfo; } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java index 621280867..8d1b811eb 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/testimpact/TestEventHandlerStrategyBase.java @@ -41,7 +41,7 @@ public abstract class TestEventHandlerStrategyBase { protected TestEventHandlerStrategyBase(AgentOptions agentOptions, JacocoRuntimeController controller) { this.controller = controller; this.agentOptions = agentOptions; - this.teamscaleClient = agentOptions.createTeamscaleClient(); + this.teamscaleClient = agentOptions.createTeamscaleClient(true); } /** Called when test test with the given name is about to start. */ @@ -65,7 +65,7 @@ public void testStart(String test) { public TestInfo testEnd(String test, TestExecution testExecution) throws JacocoRuntimeController.DumpException, CoverageGenerationException { if (testExecution != null) { - testExecution.setUniformPath(test); + testExecution.uniformPath = test; if (startTimestamp != -1) { long endTimestamp = System.currentTimeMillis(); testExecution.setDurationMillis(endTimestamp - startTimestamp); diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java index 14e080109..5db48d312 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/upload/HttpZipUploaderBase.java @@ -143,7 +143,7 @@ private File createZipFile(CoverageFile coverageFile) throws IOException { */ private void fillZipFile(ZipOutputStream zipOutputStream, CoverageFile coverageFile) throws IOException { zipOutputStream.putNextEntry(new ZipEntry(getZipEntryCoverageFileName(coverageFile))); - coverageFile.copy(zipOutputStream); + coverageFile.copyStream(zipOutputStream); for (Path additionalFile : additionalMetaDataFiles) { zipOutputStream.putNextEntry(new ZipEntry(additionalFile.getFileName().toString())); diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java index d8da410dd..fceead42b 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryConfig.java @@ -6,16 +6,14 @@ import com.teamscale.jacoco.agent.commit_resolution.git_properties.InvalidGitPropertiesException; import com.teamscale.jacoco.agent.options.AgentOptionParseException; import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import com.teamscale.jacoco.agent.options.FilePatternResolver; +import com.teamscale.jacoco.agent.upload.UploaderException; import okhttp3.HttpUrl; -import org.conqat.lib.commons.collections.Pair; +import org.jetbrains.annotations.Nullable; import java.io.File; import java.io.IOException; import java.time.format.DateTimeFormatter; -import java.util.ArrayList; import java.util.List; -import java.util.Properties; /** Config necessary to upload files to an azure file storage. */ public class ArtifactoryConfig { @@ -98,11 +96,6 @@ public class ArtifactoryConfig { /** The information regarding a commit. */ public CommitInfo commitInfo; - /** - * Related to {@link ArtifactoryConfig#ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION} - */ - public DateTimeFormatter gitPropertiesCommitTimeFormat = null; - /** Related to {@link ArtifactoryConfig#ARTIFACTORY_API_KEY_OPTION} */ public String apiKey; @@ -112,10 +105,9 @@ public class ArtifactoryConfig { /** * Handles all command-line options prefixed with 'artifactory-' * - * @return true if it has successfully process the given option. + * @return true if it has successfully processed the given option. */ - public static boolean handleArtifactoryOptions(ArtifactoryConfig options, FilePatternResolver filePatternResolver, - String key, String value) throws AgentOptionParseException { + public static boolean handleArtifactoryOptions(ArtifactoryConfig options, String key, String value) throws AgentOptionParseException { switch (key) { case ARTIFACTORY_URL_OPTION: options.url = AgentOptionsParser.parseUrl(key, value); @@ -135,13 +127,6 @@ public static boolean handleArtifactoryOptions(ArtifactoryConfig options, FilePa case ARTIFACTORY_PATH_SUFFIX: options.pathSuffix = StringUtils.stripSuffix(value, "/"); return true; - case ARTIFACTORY_GIT_PROPERTIES_JAR_OPTION: - options.commitInfo = ArtifactoryConfig.parseGitProperties(filePatternResolver, - options.gitPropertiesCommitTimeFormat, key, value); - return true; - case ARTIFACTORY_GIT_PROPERTIES_COMMIT_DATE_FORMAT_OPTION: - options.gitPropertiesCommitTimeFormat = DateTimeFormatter.ofPattern(value); - return true; case ARTIFACTORY_API_KEY_OPTION: options.apiKey = value; return true; @@ -171,49 +156,22 @@ public boolean hasCommitInfo() { } /** Parses the commit information form a git.properties file. */ - public static CommitInfo parseGitProperties(FilePatternResolver filePatternResolver, - DateTimeFormatter gitPropertiesCommitTimeFormat, String optionName, - String value) - throws AgentOptionParseException { - File jarFile = filePatternResolver.parsePath(optionName, value).toFile(); + public static CommitInfo parseGitProperties( + File jarFile, boolean searchRecursively, @Nullable DateTimeFormatter gitPropertiesCommitTimeFormat) + throws UploaderException { try { - // We can't be sure that the search-git-properties-recursively option is parsed - // already. - // Since we only support one git.properties file for artifactory anyway, - // recursive search is disabled here. - List commitInfo = parseGitProperties(jarFile, true, gitPropertiesCommitTimeFormat, false); + List commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(jarFile, true, searchRecursively, gitPropertiesCommitTimeFormat); if (commitInfo.isEmpty()) { - throw new AgentOptionParseException("Found no git.properties files in " + jarFile); + throw new UploaderException("Found no git.properties files in " + jarFile); } if (commitInfo.size() > 1) { - throw new AgentOptionParseException("Found multiple git.properties files in " + jarFile + throw new UploaderException("Found multiple git.properties files in " + jarFile + ". Uploading to multiple projects is currently not possible with Artifactory. " + "Please contact CQSE if you need this feature."); } return commitInfo.get(0); } catch (IOException | InvalidGitPropertiesException e) { - throw new AgentOptionParseException("Could not locate a valid git.properties file in " + jarFile, e); + throw new UploaderException("Could not locate a valid git.properties file in " + jarFile, e); } } - - /** Parses the commit information from a git.properties file. */ - public static List parseGitProperties(File file, boolean isJarFile, - DateTimeFormatter gitPropertiesCommitTimeFormat, - boolean recursiveSearch) - throws IOException, InvalidGitPropertiesException { - List> entriesWithProperties = GitPropertiesLocatorUtils.findGitPropertiesInFile(file, - isJarFile, recursiveSearch); - List result = new ArrayList<>(); - - for (Pair entryWithProperties : entriesWithProperties) { - String entry = entryWithProperties.getFirst(); - Properties properties = entryWithProperties.getSecond(); - - CommitInfo commitInfo = GitPropertiesLocatorUtils.getCommitInfoFromGitProperties(properties, entry, file, - gitPropertiesCommitTimeFormat); - result.add(commitInfo); - } - - return result; - } } diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java index f2b672f34..781c4f3c2 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/upload/artifactory/ArtifactoryUploader.java @@ -78,7 +78,6 @@ public void reupload(CoverageFile coverageFile, Properties reuploadProperties) { String revision = reuploadProperties.getProperty(REVISION.name()); String commitString = reuploadProperties.getProperty(COMMIT.name()); config.commitInfo = new CommitInfo(revision, CommitDescriptor.parse(commitString)); - config.gitPropertiesCommitTimeFormat = artifactoryConfig.gitPropertiesCommitTimeFormat; config.apiKey = artifactoryConfig.apiKey; config.partition = Strings.emptyToNull(reuploadProperties.getProperty(PARTITION.name())); setUploadPath(coverageFile, config); diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java index c83119b3e..5ae61e326 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/upload/azure/AzureFileStorageConfig.java @@ -25,7 +25,7 @@ public boolean hasAllRequiredFieldsNull() { /** * Handles all command-line options prefixed with 'azure-' * - * @return true if it has successfully process the given option. + * @return true if it has successfully processed the given option. */ public static boolean handleAzureFileStorageOptions(AzureFileStorageConfig azureFileStorageConfig, String key, String value) diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java index 9f563323f..ddb6bf233 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java +++ b/agent/src/main/java/com/teamscale/jacoco/agent/upload/teamscale/TeamscaleUploader.java @@ -3,8 +3,8 @@ import com.google.common.base.Strings; import com.teamscale.client.CommitDescriptor; import com.teamscale.client.EReportFormat; -import com.teamscale.client.HttpUtils; import com.teamscale.client.ITeamscaleService; +import com.teamscale.client.ITeamscaleServiceKt; import com.teamscale.client.TeamscaleServer; import com.teamscale.client.TeamscaleServiceGenerator; import com.teamscale.jacoco.agent.upload.IUploadRetry; @@ -109,7 +109,9 @@ private Properties createServerProperties() { Properties serverProperties = new Properties(); serverProperties.setProperty(PROJECT.name(), teamscaleServer.project); serverProperties.setProperty(PARTITION.name(), teamscaleServer.partition); - serverProperties.setProperty(COMMIT.name(), teamscaleServer.commit.toString()); + if (teamscaleServer.commit != null) { + serverProperties.setProperty(COMMIT.name(), teamscaleServer.commit.toString()); + } serverProperties.setProperty(REVISION.name(), Strings.nullToEmpty(teamscaleServer.revision)); serverProperties.setProperty(REPOSITORY.name(), Strings.nullToEmpty(teamscaleServer.repository)); serverProperties.setProperty(MESSAGE.name(), teamscaleServer.getMessage()); @@ -133,9 +135,8 @@ private boolean tryUploading(CoverageFile coverageFile, TeamscaleServer teamscal // Cannot be executed in the constructor as this causes issues in WildFly server // (See #100) ITeamscaleService api = TeamscaleServiceGenerator.createService(ITeamscaleService.class, - teamscaleServer.url, teamscaleServer.userName, teamscaleServer.userAccessToken, - HttpUtils.DEFAULT_READ_TIMEOUT, HttpUtils.DEFAULT_WRITE_TIMEOUT); - api.uploadReport(teamscaleServer.project, teamscaleServer.commit, teamscaleServer.revision, + teamscaleServer.url, teamscaleServer.userName, teamscaleServer.userAccessToken); + ITeamscaleServiceKt.uploadReport(api, teamscaleServer.project, teamscaleServer.commit, teamscaleServer.revision, teamscaleServer.repository, teamscaleServer.partition, EReportFormat.JACOCO, teamscaleServer.getMessage(), coverageFile.createFormRequestBody()); return true; diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java index 5a880e9a1..ec38ee8b6 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/commit_resolution/git_properties/GitMultiProjectPropertiesLocatorTest.java @@ -23,7 +23,7 @@ void testNoErrorIsThrownWhenGitPropertiesFileDoesNotHaveAProject() { new DelayedTeamscaleMultiProjectUploader((project, revision) -> { projectAndCommits.add(new ProjectAndCommit(project, revision)); return new TeamscaleServer(); - }), true); + }), true, null); File jarFile = new File(getClass().getResource("emptyTeamscaleProjectGitProperties").getFile()); locator.searchFile(jarFile, false); assertThat(projectAndCommits.size()).isEqualTo(1); @@ -41,19 +41,17 @@ void testNoMultipleUploadsToSameProjectAndRevision() { return server; }); GitMultiProjectPropertiesLocator locator = new GitMultiProjectPropertiesLocator( - delayedTeamscaleMultiProjectUploader, true + delayedTeamscaleMultiProjectUploader, true, null ); File jarFile = new File(getClass().getResource("multiple-same-target-git-properties-folder").getFile()); locator.searchFile(jarFile, false); List teamscaleServers = delayedTeamscaleMultiProjectUploader.getTeamscaleUploaders().stream() .map(TeamscaleUploader::getTeamscaleServer).collect(Collectors.toList()); - assertThat(teamscaleServers.size()).isEqualTo(2); - assertThat(teamscaleServers.get(0).project).isEqualTo("demo2"); - assertThat(teamscaleServers.get(0).commit).isEqualTo( - new CommitDescriptor("master", "1645713803000")); - assertThat(teamscaleServers.get(1).project).isEqualTo("demolib"); - assertThat(teamscaleServers.get(1).revision).isEqualTo( - "05b9d066a0c0762be622987de403b5752fa01cc0"); + assertThat(teamscaleServers).hasSize(2); + assertThat(teamscaleServers).anyMatch(server -> server.project.equals("demo2") && server.commit.equals( + new CommitDescriptor("master", "1645713803000"))); + assertThat(teamscaleServers).anyMatch(server -> server.project.equals("demolib") && server.revision.equals( + "05b9d066a0c0762be622987de403b5752fa01cc0")); } -} \ No newline at end of file +} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsParserTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsParserTest.java index 64615679c..2b74cb0e8 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsParserTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsParserTest.java @@ -27,7 +27,6 @@ public class AgentOptionsParserTest { private TeamscaleCredentials teamscaleCredentials; - private final AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), null, null, null); private Path configFile; /** The mock server to run requests against. */ protected MockWebServer mockWebServer; @@ -49,21 +48,21 @@ public void cleanup() throws Exception { @Test public void testUploadMethodRecognition() throws Exception { - assertThat(parser.parse(null).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.LOCAL_DISK); - assertThat(parser.parse("azure-url=azure.com,azure-key=key").determineUploadMethod()).isEqualTo( + assertThat(parseAndThrow(null).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.LOCAL_DISK); + assertThat(parseAndThrow("azure-url=azure.com,azure-key=key").determineUploadMethod()).isEqualTo( AgentOptions.EUploadMethod.AZURE_FILE_STORAGE); - assertThat(parser.parse( + assertThat(parseAndThrow( String.format("%s=%s,%s=%s,%s=%s", ArtifactoryConfig.ARTIFACTORY_URL_OPTION, "http://some_url", ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION, "apikey", ArtifactoryConfig.ARTIFACTORY_PARTITION, "partition") ).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.ARTIFACTORY); String basicTeamscaleOptions = "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p"; - assertThat(parser.parse(basicTeamscaleOptions) + assertThat(parseAndThrow(basicTeamscaleOptions) .determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.TEAMSCALE_MULTI_PROJECT); - assertThat(parser.parse(basicTeamscaleOptions + ",teamscale-project=proj") + assertThat(parseAndThrow(basicTeamscaleOptions + ",teamscale-project=proj") .determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.TEAMSCALE_SINGLE_PROJECT); - assertThat(parser.parse( + assertThat(parseAndThrow( basicTeamscaleOptions + ",sap-nwdi-applications=com.package.MyClass:projectId;com.company.Main:project") .determineUploadMethod()) .isEqualTo(AgentOptions.EUploadMethod.SAP_NWDI_TEAMSCALE); @@ -74,21 +73,21 @@ public void testUploadMethodRecognitionWithTeamscaleProperties() throws Exceptio TeamscaleCredentials credentials = new TeamscaleCredentials(HttpUrl.get("http://localhost"), "user", "key"); AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), null, null, credentials); - assertThat(parser.parse(null).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.LOCAL_DISK); - assertThat(parser.parse("azure-url=azure.com,azure-key=key").determineUploadMethod()).isEqualTo( + assertThat(parseAndThrow(null).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.LOCAL_DISK); + assertThat(parseAndThrow("azure-url=azure.com,azure-key=key").determineUploadMethod()).isEqualTo( AgentOptions.EUploadMethod.AZURE_FILE_STORAGE); - assertThat(parser.parse( + assertThat(parseAndThrow( String.format("%s=%s,%s=%s,%s=%s", ArtifactoryConfig.ARTIFACTORY_URL_OPTION, "http://some_url", ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION, "apikey", ArtifactoryConfig.ARTIFACTORY_PARTITION, "partition") ).determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.ARTIFACTORY); String basicTeamscaleOptions = "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p"; - assertThat(parser.parse(basicTeamscaleOptions) + assertThat(parseAndThrow(basicTeamscaleOptions) .determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.TEAMSCALE_MULTI_PROJECT); - assertThat(parser.parse(basicTeamscaleOptions + ",teamscale-project=proj") + assertThat(parseAndThrow(basicTeamscaleOptions + ",teamscale-project=proj") .determineUploadMethod()).isEqualTo(AgentOptions.EUploadMethod.TEAMSCALE_SINGLE_PROJECT); - assertThat(parser.parse( + assertThat(parseAndThrow( basicTeamscaleOptions + ",sap-nwdi-applications=com.package.MyClass:projectId;com.company.Main:project") .determineUploadMethod()) .isEqualTo(AgentOptions.EUploadMethod.SAP_NWDI_TEAMSCALE); @@ -104,7 +103,7 @@ public void environmentConfigIdOverridesCommandLineOptions() throws Exception { mockWebServer.enqueue(new MockResponse().setBody(JsonUtils.serialize(registration))); AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), "my-config", null, teamscaleCredentials); - AgentOptions options = parser.parse("teamscale-partition=bar"); + AgentOptions options = parseAndThrow(parser, "teamscale-partition=bar"); assertThat(options.teamscaleServer.partition).isEqualTo("foo"); } @@ -113,7 +112,7 @@ public void environmentConfigIdOverridesCommandLineOptions() throws Exception { public void environmentConfigFileOverridesCommandLineOptions() throws Exception { AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), null, configFile.toString(), teamscaleCredentials); - AgentOptions options = parser.parse("teamscale-partition=from-command-line"); + AgentOptions options = parseAndThrow(parser, "teamscale-partition=from-command-line"); assertThat(options.teamscaleServer.partition).isEqualTo("from-config-file"); } @@ -128,7 +127,7 @@ public void environmentConfigFileOverridesConfigId() throws Exception { mockWebServer.enqueue(new MockResponse().setBody(JsonUtils.serialize(registration))); AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), "my-config", configFile.toString(), teamscaleCredentials); - AgentOptions options = parser.parse("teamscale-partition=from-command-line"); + AgentOptions options = parseAndThrow(parser, "teamscale-partition=from-command-line"); assertThat(options.teamscaleServer.partition).isEqualTo("from-config-file"); } @@ -136,59 +135,59 @@ public void environmentConfigFileOverridesConfigId() throws Exception { @Test public void notAllRequiredTeamscaleOptionsSet() { assertThatCode( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p,teamscale-project=proj") ).doesNotThrowAnyException(); assertThatCode( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p") ).doesNotThrowAnyException(); assertThatCode( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token") ).doesNotThrowAnyException(); assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-project=proj") ).hasMessageContaining("You configured a 'teamscale-project' but no 'teamscale-partition' to upload to."); assertThatThrownBy( - () -> parser.parse("teamscale-server-url=teamscale.com") + () -> parseAndThrow("teamscale-server-url=teamscale.com") ).hasMessageContaining("not all required ones"); assertThatThrownBy( - () -> parser.parse("teamscale-server-url=teamscale.com,teamscale-user=user") + () -> parseAndThrow("teamscale-server-url=teamscale.com,teamscale-user=user") ).hasMessageContaining("not all required ones"); assertThatThrownBy( - () -> parser.parse("teamscale-server-url=teamscale.com,teamscale-access-token=token") + () -> parseAndThrow("teamscale-server-url=teamscale.com,teamscale-access-token=token") ).hasMessageContaining("not all required ones"); assertThatThrownBy( - () -> parser.parse("teamscale-user=user,teamscale-access-token=token") + () -> parseAndThrow("teamscale-user=user,teamscale-access-token=token") ).hasMessageContaining("not all required ones"); assertThatThrownBy( - () -> parser.parse("teamscale-revision=1234") + () -> parseAndThrow("teamscale-revision=1234") ).hasMessageContaining("not all required ones"); assertThatThrownBy( - () -> parser.parse("teamscale-commit=master:1234") + () -> parseAndThrow("teamscale-commit=master:1234") ).hasMessageContaining("not all required ones"); } @Test public void sapNwdiRequiresAllTeamscaleOptionsExceptProject() { assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,sap-nwdi-applications=com.package.MyClass:projectId;com.company.Main:project") ).hasMessageContaining( "You provided an SAP NWDI applications config, but the 'teamscale-' upload options are incomplete"); assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p,teamscale-project=proj,sap-nwdi-applications=com.package.MyClass:projectId;com.company.Main:project") ).hasMessageContaining( "The project must be specified via sap-nwdi-applications"); assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p,teamscale-project=proj,sap-nwdi-applications=com.package.MyClass:projectId;com.company.Main:project") ).hasMessageContaining( "You provided an SAP NWDI applications config and a teamscale-project") @@ -198,11 +197,11 @@ public void sapNwdiRequiresAllTeamscaleOptionsExceptProject() { @Test public void revisionOrCommitRequireProject() { assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p,teamscale-revision=12345") ).hasMessageContaining("you did not provide the 'teamscale-project'"); assertThatThrownBy( - () -> parser.parse( + () -> parseAndThrow( "teamscale-server-url=teamscale.com,teamscale-user=user,teamscale-access-token=token,teamscale-partition=p,teamscale-commit=master:HEAD") ).hasMessageContaining("you did not provide the 'teamscale-project'"); } @@ -219,28 +218,37 @@ public void environmentConfigIdDoesNotExist() { @Test public void notGivingAnyOptionsShouldBeOK() throws Exception { - parser.parse(""); - parser.parse(null); + parseAndThrow(""); + parseAndThrow(null); } @Test public void mustPreserveDefaultExcludes() throws Exception { - assertThat(parser.parse("").jacocoExcludes).isEqualTo(AgentOptions.DEFAULT_EXCLUDES); - assertThat(parser.parse("excludes=**foo**").jacocoExcludes) + assertThat(parseAndThrow("").jacocoExcludes).isEqualTo(AgentOptions.DEFAULT_EXCLUDES); + assertThat(parseAndThrow("excludes=**foo**").jacocoExcludes) .isEqualTo("**foo**:" + AgentOptions.DEFAULT_EXCLUDES); } @Test public void teamscalePropertiesCredentialsUsedAsDefaultButOverridable() throws Exception { - AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), null, null, teamscaleCredentials); - - assertThat(parser.parse("teamscale-project=p,teamscale-partition=p").teamscaleServer.userName).isEqualTo( + assertThat(parseAndThrow(new AgentOptionsParser(new CommandLineLogger(), null, null, teamscaleCredentials), "teamscale-project=p,teamscale-partition=p").teamscaleServer.userName).isEqualTo( "user"); - assertThat(parser.parse( + assertThat(parseAndThrow(new AgentOptionsParser(new CommandLineLogger(), null, null, teamscaleCredentials), "teamscale-project=p,teamscale-partition=p,teamscale-user=user2").teamscaleServer.userName).isEqualTo( "user2"); } + private AgentOptions parseAndThrow(AgentOptionsParser parser, String options) throws Exception { + AgentOptions result = parser.parse(options); + parser.throwOnCollectedErrors(); + return result; + } + + private AgentOptions parseAndThrow(String options) throws Exception { + AgentOptionsParser parser = new AgentOptionsParser(new CommandLineLogger(), null, null, null); + return parseAndThrow(parser, options); + } + /** * Delete created coverage folders */ diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java index d0020f8fa..b7f03e4ad 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java @@ -1,21 +1,34 @@ package com.teamscale.jacoco.agent.options; import com.teamscale.client.CommitDescriptor; +import com.teamscale.client.JsonUtils; +import com.teamscale.client.ProfilerConfiguration; +import com.teamscale.client.ProfilerRegistration; +import com.teamscale.client.ProxySystemProperties; +import com.teamscale.client.TeamscaleProxySystemProperties; import com.teamscale.client.TeamscaleServer; import com.teamscale.jacoco.agent.upload.artifactory.ArtifactoryConfig; import com.teamscale.jacoco.agent.util.TestUtils; import com.teamscale.report.util.CommandLineLogger; import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.BufferedWriter; import java.io.File; +import java.io.FileWriter; import java.io.IOException; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.function.Predicate; +import static com.teamscale.client.HttpUtils.PROXY_AUTHORIZATION_HTTP_HEADER; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -57,11 +70,11 @@ public void testIncludePatternMatching() throws Exception { /** Interval options test. */ @Test public void testIntervalOptions() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse(""); + AgentOptions agentOptions = parseAndMaybeThrow(""); assertThat(agentOptions.getDumpIntervalInMinutes()).isEqualTo(480); - agentOptions = getAgentOptionsParserWithDummyLogger().parse("interval=0"); + agentOptions = parseAndMaybeThrow("interval=0"); assertThat(agentOptions.shouldDumpInIntervals()).isEqualTo(false); - agentOptions = getAgentOptionsParserWithDummyLogger().parse("interval=30"); + agentOptions = parseAndMaybeThrow("interval=30"); assertThat(agentOptions.shouldDumpInIntervals()).isEqualTo(true); assertThat(agentOptions.getDumpIntervalInMinutes()).isEqualTo(30); } @@ -69,7 +82,7 @@ public void testIntervalOptions() throws Exception { /** Tests the options for uploading coverage to teamscale. */ @Test public void testTeamscaleUploadOptions() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("" + + AgentOptions agentOptions = parseAndMaybeThrow("" + "teamscale-server-url=127.0.0.1," + "teamscale-project=test," + "teamscale-user=build," + @@ -91,7 +104,7 @@ public void testTeamscaleUploadOptions() throws Exception { /** Tests the options for the Test Impact mode. */ @Test public void testHttpServerOptions() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("mode=TESTWISE,class-dir=.," + + AgentOptions agentOptions = parseAndMaybeThrow("mode=TESTWISE,class-dir=.," + "http-server-port=8081"); assertThat(agentOptions.getHttpServerPort()).isEqualTo(8081); } @@ -99,14 +112,14 @@ public void testHttpServerOptions() throws Exception { /** Tests the options http-server-port option for normal mode. */ @Test public void testHttpServerOptionsForNormalMode() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("http-server-port=8081"); + AgentOptions agentOptions = parseAndMaybeThrow("http-server-port=8081"); assertThat(agentOptions.getHttpServerPort()).isEqualTo(8081); } /** Tests the options for the Test Impact mode. */ @Test public void testHttpServerOptionsWithCoverageViaHttp() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("mode=TESTWISE,class-dir=.," + + AgentOptions agentOptions = parseAndMaybeThrow("mode=TESTWISE,class-dir=.," + "http-server-port=8081,tia-mode=http"); assertThat(agentOptions.getHttpServerPort()).isEqualTo(8081); assertThat(agentOptions.getTestwiseCoverageMode()).isEqualTo(ETestwiseCoverageMode.HTTP); @@ -115,21 +128,21 @@ public void testHttpServerOptionsWithCoverageViaHttp() throws Exception { /** Tests setting ignore-uncovered-classes works. */ @Test public void testIgnoreUncoveredClasses() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("ignore-uncovered-classes=true"); + AgentOptions agentOptions = parseAndMaybeThrow("ignore-uncovered-classes=true"); assertTrue(agentOptions.shouldIgnoreUncoveredClasses()); } /** Tests default for ignore-uncovered-classes is false. */ @Test public void testIgnoreUncoveredClassesDefault() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse(""); + AgentOptions agentOptions = parseAndMaybeThrow(""); assertFalse(agentOptions.shouldIgnoreUncoveredClasses()); } /** Tests default for ignore-uncovered-classes is false. */ @Test public void shouldAllowMinusForEnumConstants() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("tia-mode=exec-file"); + AgentOptions agentOptions = parseAndMaybeThrow("tia-mode=exec-file"); assertThat(agentOptions.getTestwiseCoverageMode()).isEqualTo(ETestwiseCoverageMode.EXEC_FILE); } @@ -142,19 +155,19 @@ public void testBothRevisionAndCommitSupplied() throws URISyntaxException { File jar = new File(getClass().getResource("manifest-and-git-properties.jar").toURI()); assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-revision=1234,teamscale-commit=master:1000")) .isInstanceOf(AgentOptionParseException.class).hasMessageContaining(message); assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-revision=1234,teamscale-commit-manifest-jar=" + jar.getAbsolutePath())) .isInstanceOf(AgentOptionParseException.class).hasMessageContaining(message); assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-revision-manifest-jar=" + jar.getAbsolutePath() + ",teamscale-commit=master:1000")) .isInstanceOf(AgentOptionParseException.class).hasMessageContaining(message); assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-revision-manifest-jar=" + jar.getAbsolutePath() + ",teamscale-commit-manifest-jar=" + jar.getAbsolutePath())) .isInstanceOf(AgentOptionParseException.class).hasMessageContaining(message); } @@ -163,7 +176,7 @@ public void testBothRevisionAndCommitSupplied() throws URISyntaxException { @Test public void testTeamscaleRevisionManifestJarOption() throws Exception { File jar = new File(getClass().getResource("manifest-with-git-commit-revision.jar").toURI()); - AgentOptions options = getAgentOptionsParserWithDummyLogger().parse( + AgentOptions options = parseAndMaybeThrow( "teamscale-revision-manifest-jar=" + jar.getAbsolutePath() + ",teamscale-server-url=ts.com,teamscale-user=u,teamscale-access-token=t,teamscale-project=p,teamscale-partition=p"); assertThat(options.getTeamscaleServerOptions().revision).isEqualTo("f364d58dc4966ca856260185e46a90f80ee5e9c6"); @@ -180,7 +193,7 @@ public void testNoCommitOrRevisionGivenWhenProjectNull() throws Exception { " This is not possible, since you did not provide the 'teamscale-project' to upload to"; assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-server-url=127.0.0.1," + "teamscale-user=build," + "teamscale-access-token=token," + @@ -189,7 +202,7 @@ public void testNoCommitOrRevisionGivenWhenProjectNull() throws Exception { "teamscale-message=\"This is my message\"")) .isInstanceOf(AgentOptionParseException.class).hasMessageContaining(message); assertThatThrownBy( - () -> getAgentOptionsParserWithDummyLogger().parse( + () -> parseAndMaybeThrow( "teamscale-server-url=127.0.0.1," + "teamscale-user=build," + "teamscale-access-token=token," + @@ -206,7 +219,7 @@ public void testNoCommitOrRevisionGivenWhenProjectNull() throws Exception { @Test public void testGitPropertiesJarOptionWithNonExistentFileDoesNotFailBadly() throws Exception { File jarFile = new File(getClass().getResource("nested-jar.war").getFile()); - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse( + AgentOptions agentOptions = parseAndMaybeThrow( AgentOptions.GIT_PROPERTIES_JAR_OPTION + "=doesNotExist" + File.separator + jarFile.getAbsolutePath()); assertThat(agentOptions.gitPropertiesJar).isNull(); } @@ -215,7 +228,7 @@ public void testGitPropertiesJarOptionWithNonExistentFileDoesNotFailBadly() thro @Test public void testGitPropertiesJarOptionParsedCorrectly() throws Exception { File jarFile = new File(getClass().getResource("nested-jar.war").getFile()); - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse( + AgentOptions agentOptions = parseAndMaybeThrow( AgentOptions.GIT_PROPERTIES_JAR_OPTION + "=" + jarFile.getAbsolutePath()); assertThat(agentOptions.gitPropertiesJar).isNotNull(); } @@ -227,7 +240,7 @@ public void testGitPropertiesJarOptionParsedCorrectly() throws Exception { @Test public void testGitPropertiesJarDoesNotAcceptFolders() throws Exception { File jarFile = new File(getClass().getResource("nested-jar.war").getFile()); - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse( + AgentOptions agentOptions = parseAndMaybeThrow( AgentOptions.GIT_PROPERTIES_JAR_OPTION + "=" + jarFile.getParent()); assertThat(agentOptions.gitPropertiesJar).isNull(); } @@ -235,11 +248,11 @@ public void testGitPropertiesJarDoesNotAcceptFolders() throws Exception { /** Tests that supplying version info is supported in Testwise mode. */ @Test public void testVersionInfosInTestwiseMode() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("mode=TESTWISE,class-dir=.," + + AgentOptions agentOptions = parseAndMaybeThrow("mode=TESTWISE,class-dir=.," + "http-server-port=8081,teamscale-revision=1234,teamscale-server-url=ts.com,teamscale-user=u,teamscale-access-token=t,teamscale-project=p,teamscale-partition=p"); assertThat(agentOptions.getTeamscaleServerOptions().revision).isEqualTo("1234"); - agentOptions = getAgentOptionsParserWithDummyLogger().parse("mode=TESTWISE,class-dir=.," + + agentOptions = parseAndMaybeThrow("mode=TESTWISE,class-dir=.," + "http-server-port=8081,teamscale-commit=branch:1234,teamscale-server-url=ts.com,teamscale-user=u,teamscale-access-token=t,teamscale-project=p,teamscale-partition=p"); assertThat(agentOptions.getTeamscaleServerOptions().commit).isEqualTo(CommitDescriptor.parse("branch:1234")); } @@ -247,7 +260,7 @@ public void testVersionInfosInTestwiseMode() throws Exception { /** Tests the options for azure file storage upload. */ @Test public void testAzureFileStorageOptions() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("" + + AgentOptions agentOptions = parseAndMaybeThrow("" + "azure-url=https://mrteamscaleshdev.file.core.windows.net/tstestshare/," + "azure-key=Ut0BQ2OEvgQXGnNJEjxnaEULAYgBpAK9+HukeKSzAB4CreIQkl2hikIbgNe4i+sL0uAbpTrFeFjOzh3bAtMMVg=="); assertThat(agentOptions.azureFileStorageConfig.url.toString()) @@ -259,7 +272,7 @@ public void testAzureFileStorageOptions() throws Exception { /** Tests the options for SAP NWDI applications. */ @Test public void testValidSapNwdiOptions() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse("" + + AgentOptions agentOptions = parseAndMaybeThrow("" + "teamscale-server-url=http://your.teamscale.url," + "teamscale-user=your-user-name," + "teamscale-access-token=your-access-token," + @@ -288,7 +301,7 @@ public void testValidSapNwdiOptions() throws Exception { @Test public void testArtifactoryApiKeyOptionIsCorrectlyParsed() throws Exception { String someArtifactoryApiKey = "some_api_key"; - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse( + AgentOptions agentOptions = parseAndMaybeThrow( String.format("%s=%s,%s=%s,%s=%s", ArtifactoryConfig.ARTIFACTORY_URL_OPTION, "http://some_url", ArtifactoryConfig.ARTIFACTORY_API_KEY_OPTION, someArtifactoryApiKey, ArtifactoryConfig.ARTIFACTORY_PARTITION, "partition")); @@ -301,8 +314,8 @@ public void testArtifactoryApiKeyOptionIsCorrectlyParsed() throws Exception { * {@link ArtifactoryConfig#ARTIFACTORY_URL_OPTION}) passes the AgentOptions' validity check. */ @Test - public void testArtifactoryBasicAuthSetPassesValiditiyCheck() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse(""); + public void testArtifactoryBasicAuthSetPassesValidityCheck() throws Exception { + AgentOptions agentOptions = parseAndMaybeThrow(""); agentOptions.artifactoryConfig.url = HttpUrl.get("http://some_url"); agentOptions.artifactoryConfig.user = "user"; agentOptions.artifactoryConfig.password = "password"; @@ -317,13 +330,143 @@ public void testArtifactoryBasicAuthSetPassesValiditiyCheck() throws Exception { */ @Test public void testArtifactoryApiKeySetPassesValidityCheck() throws Exception { - AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger().parse(""); + AgentOptions agentOptions = parseAndMaybeThrow(""); agentOptions.artifactoryConfig.url = HttpUrl.get("http://some_url"); agentOptions.artifactoryConfig.apiKey = "api_key"; agentOptions.artifactoryConfig.partition = "partition"; assertThat(agentOptions.getValidator().isValid()).isTrue(); } + /** + * Tests that the {@link TeamscaleProxyOptions} for HTTP are parsed correctly and correctly put into + * system properties that can be read using {@link TeamscaleProxySystemProperties}. + */ + @Test + public void testTeamscaleProxyOptionsCorrectlySetSystemPropertiesForHttp() throws Exception { + testTeamscaleProxyOptionsCorrectlySetSystemProperties(ProxySystemProperties.Protocol.HTTP); + } + + /** + * Tests that the {@link TeamscaleProxyOptions} for HTTPS are parsed correctly and correctly put into + * system properties that can be read using {@link TeamscaleProxySystemProperties}. + */ + @Test + public void testTeamscaleProxyOptionsCorrectlySetSystemPropertiesForHttps() throws Exception { + testTeamscaleProxyOptionsCorrectlySetSystemProperties(ProxySystemProperties.Protocol.HTTPS); + } + + /** + * Temporary folder to create the password file for + * {@link AgentOptionsTest#testTeamscaleProxyOptionsAreUsedWhileFetchingConfigFromTeamscale()}. + */ + @TempDir + public File temporaryDirectory; + + /** + * Test that the proxy settings are put into system properties and used for fetching a profiler configuration from + * Teamscale. Also tests that it is possible to specify the proxy password in a file and that this overwrites the + * password specified as agent option. + */ + @Test + public void testTeamscaleProxyOptionsAreUsedWhileFetchingConfigFromTeamscale() throws Exception { + String expectedUser = "user"; + // this is the password passed as agent property, it should be overwritten by the password file + String unexpectedPassword = "not-my-password"; + + String expectedPassword = "password"; + File passwordFile = writePasswortToPasswordFile(expectedPassword); + + try (MockWebServer mockProxyServer = new MockWebServer()) { + String expectedHost = mockProxyServer.getHostName(); + int expectedPort = mockProxyServer.getPort(); + + + ProfilerConfiguration expectedProfilerConfiguration = new ProfilerConfiguration(); + expectedProfilerConfiguration.configurationId = "config-id"; + expectedProfilerConfiguration.configurationOptions = "mode=testwise\ntia-mode=disk"; + ProfilerRegistration profilerRegistration = new ProfilerRegistration(); + profilerRegistration.profilerId = "profiler-id"; + profilerRegistration.profilerConfiguration = expectedProfilerConfiguration; + + mockProxyServer.enqueue(new MockResponse().setResponseCode(407)); + mockProxyServer.enqueue(new MockResponse().setResponseCode(200).setBody(JsonUtils.serialize(profilerRegistration))); + + AgentOptions agentOptions= parseProxyOptions("config-id=config,", ProxySystemProperties.Protocol.HTTP, expectedHost, expectedPort, expectedUser, unexpectedPassword, passwordFile); + + assertThat(agentOptions.configurationViaTeamscale.getProfilerConfiguration().configurationId).isEqualTo(expectedProfilerConfiguration.configurationId); + assertThat(agentOptions.mode).isEqualTo(EMode.TESTWISE); + + // 2 requests: one without proxy authentication, which failed (407), one with proxy authentication + assertThat(mockProxyServer.getRequestCount()).isEqualTo(2); + + mockProxyServer.takeRequest(); + RecordedRequest requestWithProxyAuth = mockProxyServer.takeRequest(); // this is the interesting request + + // check that the correct password was used + String base64EncodedBasicAuth = Base64.getEncoder().encodeToString((expectedUser + ":" + expectedPassword).getBytes( + StandardCharsets.UTF_8)); + assertThat(requestWithProxyAuth.getHeader(PROXY_AUTHORIZATION_HTTP_HEADER)).isEqualTo("Basic " + base64EncodedBasicAuth); + } + + } + + private File writePasswortToPasswordFile(String expectedPassword) throws IOException { + File passwordFile = new File(temporaryDirectory, "password.txt"); + + BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(passwordFile)); + bufferedWriter.write(expectedPassword); + bufferedWriter.close(); + + return passwordFile; + } + + private void testTeamscaleProxyOptionsCorrectlySetSystemProperties(ProxySystemProperties.Protocol protocol) throws Exception { + String expectedHost = "host"; + int expectedPort = 9999; + String expectedUser = "user"; + String expectedPassword = "password"; + AgentOptions agentOptions = parseProxyOptions("", protocol, + expectedHost, expectedPort, expectedUser, expectedPassword, null); + + // clear to be sure the system properties are empty + clearTeamscaleProxySystemProperties(protocol); + + AgentOptionsParser.putTeamscaleProxyOptionsIntoSystemProperties(agentOptions); + + assertTeamscaleProxySystemPropertiesAreCorrect(protocol, expectedHost, expectedPort, expectedUser, expectedPassword); + + clearTeamscaleProxySystemProperties(protocol); + } + + private static AgentOptions parseProxyOptions(String otherOptionsString, ProxySystemProperties.Protocol protocol, + String expectedHost, int expectedPort, String expectedUser, + String expectedPassword, File passwordFile) throws Exception { + String proxyHostOption = String.format("proxy-%s-host=%s", protocol, expectedHost); + String proxyPortOption = String.format("proxy-%s-port=%d", protocol, expectedPort); + String proxyUserOption = String.format("proxy-%s-user=%s", protocol, expectedUser); + String proxyPasswordOption = String.format("proxy-%s-password=%s", protocol, expectedPassword); + String optionsString = String.format("%s%s,%s,%s,%s", otherOptionsString, proxyHostOption, proxyPortOption, proxyUserOption, proxyPasswordOption); + + if (passwordFile != null) { + String proxyPasswordFileOption = String.format("proxy-password-file=%s", passwordFile.getAbsoluteFile()); + optionsString += "," + proxyPasswordFileOption; + } + + TeamscaleCredentials credentials = new TeamscaleCredentials(HttpUrl.parse("http://localhost:80"), "unused", "unused"); + return getAgentOptionsParserWithDummyLoggerAndCredentials(credentials).parse(optionsString); + } + + private void assertTeamscaleProxySystemPropertiesAreCorrect(ProxySystemProperties.Protocol protocol, String expectedHost, int expectedPort, String expectedUser, String expectedPassword) throws ProxySystemProperties.IncorrectPortFormatException { + TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol); + assertThat(teamscaleProxySystemProperties.getProxyHost()).isEqualTo(expectedHost); + assertThat(teamscaleProxySystemProperties.getProxyPort()).isEqualTo(expectedPort); + assertThat(teamscaleProxySystemProperties.getProxyUser()).isEqualTo(expectedUser); + assertThat(teamscaleProxySystemProperties.getProxyPassword()).isEqualTo(expectedPassword); + } + + private void clearTeamscaleProxySystemProperties(ProxySystemProperties.Protocol protocol) { + new TeamscaleProxySystemProperties(protocol).clear(); + } /** Returns the include filter predicate for the given filter expression. */ private static Predicate includeFilter(String filterString) throws Exception { AgentOptions agentOptions = getAgentOptionsParserWithDummyLogger() @@ -342,6 +485,10 @@ private static AgentOptionsParser getAgentOptionsParserWithDummyLogger() { return new AgentOptionsParser(new CommandLineLogger(), null, null, null); } + private static AgentOptionsParser getAgentOptionsParserWithDummyLoggerAndCredentials(TeamscaleCredentials credentials) { + return new AgentOptionsParser(new CommandLineLogger(), null, null, credentials); + } + /** * Delete created coverage folders */ @@ -349,4 +496,11 @@ private static AgentOptionsParser getAgentOptionsParserWithDummyLogger() { public static void teardown() throws IOException { TestUtils.cleanAgentCoverageDirectory(); } + + private AgentOptions parseAndMaybeThrow(String options) throws Exception { + AgentOptionsParser parser = getAgentOptionsParserWithDummyLogger(); + AgentOptions result = parser.parse(options); + parser.throwOnCollectedErrors(); + return result; + } } diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptionsTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptionsTest.java new file mode 100644 index 000000000..e1fb23b99 --- /dev/null +++ b/agent/src/test/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptionsTest.java @@ -0,0 +1,31 @@ +package com.teamscale.jacoco.agent.options; + +import com.teamscale.client.ProxySystemProperties; +import com.teamscale.report.util.CommandLineLogger; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TeamscaleProxyOptionsTest { + + @Test + void testTeamscaleProxyOptionsFilledWithJVMOptionsOnInit() { + ProxySystemProperties proxySystemProperties =new ProxySystemProperties(ProxySystemProperties.Protocol.HTTP); + String expectedHost = "testHost"; + proxySystemProperties.setProxyHost(expectedHost); + int expectedPort = 1234; + proxySystemProperties.setProxyPort(expectedPort); + String expectedUser = "testUser"; + proxySystemProperties.setProxyUser(expectedUser); + String expectedPassword = "testPassword"; + proxySystemProperties.setProxyPassword(expectedPassword); + + TeamscaleProxyOptions teamscaleProxyOptions = new TeamscaleProxyOptions(ProxySystemProperties.Protocol.HTTP, new CommandLineLogger()); + assertThat(teamscaleProxyOptions.proxyHost).isEqualTo(expectedHost); + assertThat(teamscaleProxyOptions.proxyPort).isEqualTo(expectedPort); + assertThat(teamscaleProxyOptions.proxyUser).isEqualTo(expectedUser); + assertThat(teamscaleProxyOptions.proxyPassword).isEqualTo(expectedPassword); + + proxySystemProperties.clear(); + } +} \ No newline at end of file diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java index da3e24bb9..14233b1f7 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/CoverageToTeamscaleStrategyTest.java @@ -113,7 +113,7 @@ protected static TestwiseCoverage getDummyTestwiseCoverage(String test) { private AgentOptions mockOptions() throws IOException { AgentOptions options = mock(AgentOptions.class); - when(options.createTeamscaleClient()).thenReturn(client); + when(options.createTeamscaleClient(true)).thenReturn(client); when(options.createNewFileInOutputDirectory(any(), any())).thenReturn(new File(tempDir, "test")); TeamscaleServer server = new TeamscaleServer(); @@ -124,7 +124,7 @@ private AgentOptions mockOptions() throws IOException { server.partition = "partition"; when(options.getTeamscaleServerOptions()).thenReturn(server); - when(options.createTeamscaleClient()).thenReturn(client); + when(options.createTeamscaleClient(true)).thenReturn(client); return options; } diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java index 40df45d00..aa8ef3a06 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/testimpact/TestwiseCoverageAgentTest.java @@ -1,27 +1,5 @@ package com.teamscale.jacoco.agent.testimpact; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.matches; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.io.File; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - import com.teamscale.client.ClusteredTestDetails; import com.teamscale.client.CommitDescriptor; import com.teamscale.client.EReportFormat; @@ -31,16 +9,21 @@ import com.teamscale.client.TeamscaleServer; import com.teamscale.jacoco.agent.options.AgentOptions; import com.teamscale.jacoco.agent.options.ETestwiseCoverageMode; +import com.teamscale.jacoco.agent.util.TestUtils; import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; import com.teamscale.report.testwise.model.ETestExecutionResult; import com.teamscale.tia.client.RunningTest; import com.teamscale.tia.client.TestRun; import com.teamscale.tia.client.TestRunWithClusteredSuggestions; import com.teamscale.tia.client.TiaAgent; - import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.ResponseBody; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import retrofit2.Call; import retrofit2.Response; import retrofit2.Retrofit; @@ -48,6 +31,21 @@ import retrofit2.http.POST; import retrofit2.http.Query; +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + @ExtendWith(MockitoExtension.class) public class TestwiseCoverageAgentTest { private static final String FORBIDDEN_MESSAGE_PREFIX = "HTTP Status Code: 403 Forbidden\nMessage: "; @@ -63,12 +61,6 @@ public class TestwiseCoverageAgentTest { @TempDir File tempDir; - /** - * Ensures that each test case gets it's own port number, so each tested instance of the agent runs it's REST API on - * a separate port. - */ - private static final AtomicInteger PORT_COUNTER = new AtomicInteger(54321); - @Test public void testAccessViaTiaClientAndReportUploadToTeamscale() throws Exception { List availableTests = Arrays @@ -83,17 +75,20 @@ public void testAccessViaTiaClientAndReportUploadToTeamscale() throws Exception when(reportGenerator.convert(any(File.class))) .thenReturn(CoverageToTeamscaleStrategyTest.getDummyTestwiseCoverage("test2")); - int port = PORT_COUNTER.incrementAndGet(); - AgentOptions options = mockOptions(port); - when(options.createNewFileInOutputDirectory(any(), any())).thenReturn(new File(tempDir, "test")); - new TestwiseCoverageAgent(options, null, reportGenerator); + int port; + synchronized (TestUtils.class) { + port = TestUtils.getFreePort(); + AgentOptions options = mockOptions(port); + when(options.createNewFileInOutputDirectory(any(), any())).thenReturn(new File(tempDir, "test")); + new TestwiseCoverageAgent(options, null, reportGenerator); + } TiaAgent agent = new TiaAgent(false, HttpUrl.get("http://localhost:" + port)); TestRunWithClusteredSuggestions testRun = agent.startTestRun(availableTests); - assertThat(testRun.getPrioritizedClusters()).hasSize(1); - assertThat(testRun.getPrioritizedClusters().get(0).tests).hasSize(1); - PrioritizableTest test = testRun.getPrioritizedClusters().get(0).tests.get(0); + assertThat(testRun.prioritizedClusters).hasSize(1); + assertThat(testRun.prioritizedClusters.get(0).tests).hasSize(1); + PrioritizableTest test = testRun.prioritizedClusters.get(0).tests.get(0); assertThat(test.testName).isEqualTo("test2"); RunningTest runningTest = testRun.startTest(test.testName); @@ -111,9 +106,12 @@ public void testErrorHandling() throws Exception { .thenReturn(Response.error(403, ResponseBody.create(FORBIDDEN_MESSAGE_PREFIX + MISSING_VIEW_PERMISSIONS, PLAIN_TEXT))); - int port = PORT_COUNTER.incrementAndGet(); - AgentOptions options = mockOptions(port); - new TestwiseCoverageAgent(options, null, reportGenerator); + int port; + synchronized (TestUtils.class) { + port = TestUtils.getFreePort(); + AgentOptions options = mockOptions(port); + new TestwiseCoverageAgent(options, null, reportGenerator); + } TiaAgent agent = new TiaAgent(false, HttpUrl.get("http://localhost:" + port)); assertThatCode(agent::startTestRunAssumingUnchangedTests).hasMessageContaining(MISSING_VIEW_PERMISSIONS); @@ -141,8 +139,11 @@ public void shouldHandleMissingRequestBodyForTestrunStartGracefully() throws Exc when(client.getImpactedTests(any(), any(), any(), any(), any(), any(), any(), anyBoolean(), anyBoolean(), anyBoolean())) .thenReturn(Response.success(impactedClusters)); - int port = PORT_COUNTER.incrementAndGet(); - new TestwiseCoverageAgent(mockOptions(port), null, reportGenerator); + int port; + synchronized (TestUtils.class) { + port = TestUtils.getFreePort(); + new TestwiseCoverageAgent(mockOptions(port), null, reportGenerator); + } ITestwiseCoverageAgentApiWithoutBody api = new Retrofit.Builder() .addConverterFactory(JacksonConverterFactory.create()) @@ -158,7 +159,8 @@ public void shouldHandleMissingRequestBodyForTestrunStartGracefully() throws Exc private AgentOptions mockOptions(int port) { AgentOptions options = mock(AgentOptions.class); - when(options.createTeamscaleClient()).thenReturn(client); + when(options.createTeamscaleClient(true)).thenReturn(client); + TeamscaleServer server = new TeamscaleServer(); server.commit = new CommitDescriptor("branch", "12345"); @@ -170,8 +172,7 @@ private AgentOptions mockOptions(int port) { when(options.getHttpServerPort()).thenReturn(port); when(options.getTestwiseCoverageMode()).thenReturn(ETestwiseCoverageMode.TEAMSCALE_UPLOAD); - when(options.createTeamscaleClient()).thenReturn(client); + when(options.createTeamscaleClient(true)).thenReturn(client); return options; } - } diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/upload/delay/DelayedCommitDescriptorRetrievalTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/upload/delay/DelayedCommitDescriptorRetrievalTest.java index 2320b82cf..9a6fce87b 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/upload/delay/DelayedCommitDescriptorRetrievalTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/upload/delay/DelayedCommitDescriptorRetrievalTest.java @@ -33,7 +33,7 @@ public void locatorShouldTriggerUploadOfCachedXmls(@TempDir Path outputPath) thr ExecutorService locatorExecutor = Executors.newSingleThreadExecutor(); GitSingleProjectPropertiesLocator locator = new GitSingleProjectPropertiesLocator<>(store, - GitPropertiesLocatorUtils::getCommitInfoFromGitProperties, locatorExecutor, true); + GitPropertiesLocatorUtils::getCommitInfoFromGitProperties , locatorExecutor, true, null); store.upload(coverageFile); locator.searchFileForGitPropertiesAsync(new File(getClass().getResource("git-properties.jar").toURI()), true); @@ -47,4 +47,4 @@ public void locatorShouldTriggerUploadOfCachedXmls(@TempDir Path outputPath) thr .isFalse(); assertThat(destination.getUploadedFiles().contains(coverageFile)).isTrue(); } -} \ No newline at end of file +} diff --git a/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java b/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java index 7c360b2c8..d20c47386 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/util/TestUtils.java @@ -1,6 +1,7 @@ package com.teamscale.jacoco.agent.util; import java.io.IOException; +import java.net.ServerSocket; import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Stream; @@ -25,4 +26,11 @@ public static void cleanAgentCoverageDirectory() throws IOException { } } + /** Returns a new free TCP port number */ + public static int getFreePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } + } } diff --git a/build.gradle.kts b/build.gradle.kts index fba208333..85abb933d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,12 @@ plugins { alias(libs.plugins.versions) alias(libs.plugins.nexusPublish) + kotlin("jvm") apply false } group = "com.teamscale" -val appVersion by extra("34.0.1") +val appVersion by extra("34.2.0") val snapshotVersion = appVersion + if (VersionUtils.isTaggedRelease()) "" else "-SNAPSHOT" diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index d6904def8..328e18b73 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -4,18 +4,12 @@ plugins { repositories { gradlePluginPortal() - maven("https://maven.xpdustry.com/releases") { - name = "xpdustry-releases" - mavenContent { releasesOnly() } - } } dependencies { - implementation("io.github.goooler.shadow:shadow-gradle-plugin:8.1.8") - implementation("com.xpdustry.ksr:com.xpdustry.ksr.gradle.plugin:1.0.0") { - exclude(group = "com.github.johnrengelman") - } + implementation("com.gradleup.shadow:shadow-gradle-plugin:8.3.5") + implementation("com.xpdustry:kotlin-shadow-relocator:2.0.0") - implementation("org.ow2.asm:asm:9.7") - implementation("org.ow2.asm:asm-commons:9.7") + implementation("org.ow2.asm:asm:9.7.1") + implementation("org.ow2.asm:asm-commons:9.7.1") } diff --git a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts index aa7a9333b..751f9e65b 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.publish.gradle.kts @@ -1,5 +1,3 @@ -import com.github.jengelman.gradle.plugins.shadow.ShadowExtension - plugins { java `maven-publish` @@ -42,8 +40,7 @@ fun PublicationContainer.configureMavenPublication() { val publication = this var hasShadow = false pluginManager.withPlugin("com.teamscale.shadow-convention") { - val shadowExtension = extensions.getByName("shadow") - shadowExtension.component(publication) + publication.from(components.findByName("shadow")) setArtifacts(listOf(tasks["shadowJar"])) artifact(tasks["sourcesJar"]) artifact(tasks["javadocJar"]) diff --git a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts index 7370e8465..b0a7f27b0 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.shadow-convention.gradle.kts @@ -3,21 +3,21 @@ import com.xpdustry.ksr.kotlinRelocate plugins { java - // https://github.com/Goooler/shadow - id("io.github.goooler.shadow") + // https://github.com/GradleUp/shadow + id("com.gradleup.shadow") // https://github.com/xpdustry/kotlin-shadow-relocator - id("com.xpdustry.ksr") + id("com.xpdustry.kotlin-shadow-relocator") } tasks.named("shadowJar") { isEnableRelocation = project.properties["debug"] !== "true" - // Needed as a workaround for https://github.com/johnrengelman/shadow/issues/521 + // Needed as a workaround for https://github.com/GradleUp/shadow/issues/521 inputs.property("relocation-enabled", isEnableRelocation) archiveClassifier.set(null as String?) mergeServiceFiles() manifest { // The jaxb library, which we are shading is a multi release jar, so we have to explicitly "inherit" this attribute - // https://github.com/johnrengelman/shadow/issues/449 + // https://github.com/GradleUp/shadow/issues/449 attributes["Multi-Release"] = "true" } // Relocates the .kotlin_metadata files to ensure reflection in Kotlin does not break @@ -31,7 +31,7 @@ tasks.named("shadowJar") { // Defer the resolution of 'runtimeClasspath'. This is an issue in the shadow // plugin that it automatically accesses the files in 'runtimeClasspath' while // Gradle is building the task graph. The lines below work around that. -// https://github.com/johnrengelman/shadow/issues/882 +// https://github.com/GradleUp/shadow/issues/882 tasks.withType { dependsOn(tasks.jar) inputs.files(project.configurations.runtimeClasspath) diff --git a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts index c22dcd9e2..7688d057c 100644 --- a/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/com.teamscale.system-test-convention.gradle.kts @@ -12,6 +12,10 @@ tasks.test { teamscalePort = provider.get().pickFreePort() agentPort = provider.get().pickFreePort() systemProperties("agentPort" to agentPort, "teamscalePort" to teamscalePort) + environment("AGENT_VERSION", version) + environment("AGENT_PATH", agentJar) + environment("TEAMSCALE_PORT", teamscalePort) + environment("AGENT_PORT", agentPort) } dependencies { diff --git a/common-system-test/src/main/java/com/teamscale/test/commons/SystemTestUtils.java b/common-system-test/src/main/java/com/teamscale/test/commons/SystemTestUtils.java index a83f2316a..eebc26d52 100644 --- a/common-system-test/src/main/java/com/teamscale/test/commons/SystemTestUtils.java +++ b/common-system-test/src/main/java/com/teamscale/test/commons/SystemTestUtils.java @@ -49,7 +49,7 @@ public class SystemTestUtils { * Example: {@code file1.java:1,7-12;file2.java:9-22,33} */ public static String getCoverageString(TestInfo info) { - return info.paths.stream().flatMap(path -> path.getFiles().stream()) + return info.paths.stream().flatMap(path -> path.files.stream()) .map(file -> file.fileName + ":" + file.coveredLines).collect( Collectors.joining(";")); } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae8fa1131..d8846fc74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -jetty = "9.4.55.v20240627" -jersey = "2.43" -jackson = "2.17.2" +jetty = "9.4.56.v20240826" +jersey = "2.45" +jackson = "2.18.2" # When upgrading JaCoCo to a newer version make sure to # check the comment in the OpenAnalyzer.java, JaCoCoPreMain.java and CachingInstructionsBuilder.java # and update the internal_xxxxxx hash included in the imports in LenientCoverageTransformer.java and JaCoCoPreMain.java. @@ -9,8 +9,8 @@ jacoco = "0.8.12" # We need to stay on the 1.3.x release line as 1.4.x requires Java 11 logback = "1.3.14" retrofit = "2.11.0" -junit = "5.10.3" -junitPlatform = "1.10.3" +junit = "5.11.3" +junitPlatform = "1.11.3" okhttp = "4.12.0" mockito = "4.11.0" picocli = "4.7.6" @@ -50,17 +50,17 @@ spark = { module = "com.sparkjava:spark-core", version = "2.9.4" } jcommander = { module = "com.beust:jcommander", version = "1.82" } teamscaleLibCommons = { module = "com.teamscale:teamscale-lib-commons", version = "9.4.1" } commonsCodec = { module = "commons-codec:commons-codec", version = "1.17.1" } -commonsLang = { module = "org.apache.commons:commons-lang3", version = "3.15.0" } -commonsIo = { module = "commons-io:commons-io", version = "2.16.1" } -slf4j-api = { module = "org.slf4j:slf4j-api", version = "2.0.13" } -jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.10.0.202406032230-r" } -okio = { module = "com.squareup.okio:okio", version = "3.9.0" } +commonsLang = { module = "org.apache.commons:commons-lang3", version = "3.17.0" } +commonsIo = { module = "commons-io:commons-io", version = "2.18.0" } +slf4j-api = { module = "org.slf4j:slf4j-api", version = "2.0.16" } +jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "7.0.0.202409031743-r" } +okio = { module = "com.squareup.okio:okio", version = "3.9.1" } picocli-core = { module = "info.picocli:picocli", version.ref = "picocli" } picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" } -jna-platform = { module = "net.java.dev.jna:jna-platform", version = "5.14.0" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version = "5.15.0" } -log4j-core = { module = "org.apache.logging.log4j:log4j-core", version = "2.23.1" } +log4j-core = { module = "org.apache.logging.log4j:log4j-core", version = "2.24.2" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } @@ -76,11 +76,11 @@ jsonassert = { module = "org.skyscreamer:jsonassert", version = "1.5.3" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } mockito-junit = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } -springboot-loader = { module = "org.springframework.boot:spring-boot-loader", version = "3.3.2" } +springboot-loader = { module = "org.springframework.boot:spring-boot-loader", version = "3.4.0" } [plugins] versions = { id = "com.github.ben-manes.versions", version = "0.51.0" } markdownToPdf = { id = "de.fntsoftware.gradle.markdown-to-pdf", version = "1.1.0" } nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version = "2.0.0" } -pluginPublish = { id = "com.gradle.plugin-publish", version = "1.2.1" } +pluginPublish = { id = "com.gradle.plugin-publish", version = "1.3.0" } gitProperties = { id = "com.gorylenko.gradle-git-properties", version = "2.4.2" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c3521197..a4b76b953 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dedd5d1e6..c1d5e0185 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/commons/LoggerUtils.java b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/commons/LoggerUtils.java index 94959f935..edf438f52 100644 --- a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/commons/LoggerUtils.java +++ b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/commons/LoggerUtils.java @@ -1,6 +1,11 @@ package com.teamscale.test_impacted.commons; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.logging.ConsoleHandler; +import java.util.logging.LogManager; import java.util.logging.LogRecord; import java.util.logging.Logger; import java.util.logging.SimpleFormatter; @@ -12,8 +17,12 @@ public class LoggerUtils { private static final Logger MAIN_LOGGER; + private static final String JAVA_UTIL_LOGGING_CONFIG_FILE_SYSTEM_PROPERTY = "java.util.logging.config.file"; static { + // Needs to be at the very top so it also takes affect when setting the log level for Console handlers + useDefaultJULConfigFile(); + MAIN_LOGGER = Logger.getLogger("com.teamscale"); MAIN_LOGGER.setUseParentHandlers(false); ConsoleHandler handler = new ConsoleHandler(); @@ -24,9 +33,36 @@ public synchronized String format(LogRecord lr) { return String.format("[%1$s] %2$s%n", lr.getLevel().getLocalizedName(), lr.getMessage()); } }); + MAIN_LOGGER.addHandler(handler); } + /** + * Normally, the java util logging framework picks up the config file specified via the system property + * {@value #JAVA_UTIL_LOGGING_CONFIG_FILE_SYSTEM_PROPERTY}. For some reason, this does not work here, so we need to + * teach the log manager to use it. + */ + private static void useDefaultJULConfigFile() { + String loggingPropertiesFilePathString = System.getProperty(JAVA_UTIL_LOGGING_CONFIG_FILE_SYSTEM_PROPERTY); + if (loggingPropertiesFilePathString == null) { + return; + } + + Logger logger = Logger.getLogger(LoggerUtils.class.getName()); + try { + Path loggingPropertiesFilePath = Paths.get(loggingPropertiesFilePathString); + if (!loggingPropertiesFilePath.toFile().exists()) { + logger.warning( + "Cannot find the file specified via " + JAVA_UTIL_LOGGING_CONFIG_FILE_SYSTEM_PROPERTY + ": " + loggingPropertiesFilePathString); + return; + } + LogManager.getLogManager().readConfiguration(Files.newInputStream(loggingPropertiesFilePath)); + } catch (IOException e) { + logger.warning( + "Cannot load the file specified via " + JAVA_UTIL_LOGGING_CONFIG_FILE_SYSTEM_PROPERTY + ": " + loggingPropertiesFilePathString + ". " + e.getMessage()); + } + } + /** * Returns a logger for the given class. */ diff --git a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/AvailableTests.java b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/AvailableTests.java index f80bef0f4..abaf9074f 100644 --- a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/AvailableTests.java +++ b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/AvailableTests.java @@ -53,7 +53,7 @@ public Optional convertToUniqueId(PrioritizableTest test) { LOGGER.severe(() -> "Retrieved invalid test '" + test.testName + "' from Teamscale server!"); LOGGER.severe(() -> "The following seem related:"); uniformPathToUniqueIdMapping.keySet().stream().sorted(Comparator - .comparing(testPath -> StringUtils.editDistance(test.testName, testPath))).limit(5) + .comparing(testPath -> StringUtils.levenshteinDistance(test.testName, testPath))).limit(5) .forEach(testAlternative -> LOGGER.severe(() -> " - " + testAlternative)); } return Optional.ofNullable(clusterUniqueId); diff --git a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/TeamscaleAgentNotifier.java b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/TeamscaleAgentNotifier.java index ebb9aef49..21e9710cb 100644 --- a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/TeamscaleAgentNotifier.java +++ b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/engine/executor/TeamscaleAgentNotifier.java @@ -33,7 +33,7 @@ public TeamscaleAgentNotifier(List testwiseCoverageAg public void startTest(String testUniformPath) { try { for (ITestwiseCoverageAgentApi apiService : testwiseCoverageAgentApis) { - apiService.testStarted(UrlUtils.percentEncode(testUniformPath)).execute(); + apiService.testStarted(UrlUtils.encodeUrl(testUniformPath)).execute(); } } catch (IOException e) { LOGGER.log(Level.SEVERE, e, () -> "Error while calling service api."); @@ -45,9 +45,9 @@ public void endTest(String testUniformPath, TestExecution testExecution) { try { for (ITestwiseCoverageAgentApi apiService : testwiseCoverageAgentApis) { if (testExecution == null) { - apiService.testFinished(UrlUtils.percentEncode(testUniformPath)).execute(); + apiService.testFinished(UrlUtils.encodeUrl(testUniformPath)).execute(); } else { - apiService.testFinished(UrlUtils.percentEncode(testUniformPath), testExecution).execute(); + apiService.testFinished(UrlUtils.encodeUrl(testUniformPath), testExecution).execute(); } } } catch (IOException e) { diff --git a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.java b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.java index bd76acca1..cb75e6c1b 100644 --- a/impacted-test-engine/src/main/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.java +++ b/impacted-test-engine/src/main/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolver.java @@ -27,34 +27,41 @@ public class CucumberPickleDescriptorResolver implements ITestDescriptorResolver @Override public Optional getUniformPath(TestDescriptor testDescriptor) { Optional featurePath = getFeaturePath(testDescriptor); + LOGGER.fine(() -> "Resolved feature: " + featurePath); if (!featurePath.isPresent()) { LOGGER.severe(() -> "Cannot resolve the feature classpath for " + testDescriptor + ". This is probably a bug. Please report to CQSE"); return Optional.empty(); } Optional pickleName = getPickleName(testDescriptor); + LOGGER.fine(() -> "Resolved pickle name: " + pickleName); if (!pickleName.isPresent()) { LOGGER.severe(() -> "Cannot resolve the pickle name for " + testDescriptor + ". This is probably a bug. Please report to CQSE"); return Optional.empty(); } - String uniformPath = featurePath.get() + "/" + pickleName.get(); + String picklePath = featurePath.get() + "/" + pickleName.get(); // Add an index to the end of the name in case multiple tests have the same name in the same feature file Optional featureFileTestDescriptor = getFeatureFileTestDescriptor(testDescriptor); - if (featureFileTestDescriptor.isPresent()) { + String indexSuffix; + if (!featureFileTestDescriptor.isPresent()) { + indexSuffix = ""; + } else { List siblingTestsWithTheSameName = flatListOfAllTestDescriptorChildrenWithPickleName( featureFileTestDescriptor.get(), pickleName.get()); int indexOfCurrentTest = siblingTestsWithTheSameName.indexOf(testDescriptor) + 1; - uniformPath += " #" + indexOfCurrentTest; + indexSuffix = " #" + indexOfCurrentTest; } + String uniformPath = removeDuplicatedSlashes(picklePath + indexSuffix); + LOGGER.fine(() -> "Resolved uniform path: " + uniformPath); return Optional.of(uniformPath); } @Override public Optional getClusterId(TestDescriptor testDescriptor) { - return getFeaturePath(testDescriptor); + return getFeaturePath(testDescriptor).map(this::removeDuplicatedSlashes); } @Override @@ -68,9 +75,18 @@ public String getEngineId() { * hellocucumber/calculator.feature/11/16/21 */ private Optional getFeaturePath(TestDescriptor testDescriptor) { - Optional featureClasspath = TestDescriptorUtils.getUniqueIdSegment(testDescriptor, + LOGGER.fine((() -> "Unique ID of cucumber test descriptor: " + testDescriptor.getUniqueId())); + Optional featureSegment = TestDescriptorUtils.getUniqueIdSegment(testDescriptor, FEATURE_SEGMENT_TYPE); - return featureClasspath.map(featureClasspathString -> featureClasspathString.replaceAll("classpath:", "")); + LOGGER.fine(() -> "Resolved feature segment: " + featureSegment); + return featureSegment.map(featureClasspathString -> featureClasspathString.replaceAll("classpath:", "")); + } + + /** + * Remove duplicated "/" with one (due to TS-39915) + */ + String removeDuplicatedSlashes(String string) { + return string.replaceAll("(? getPickleName(TestDescriptor testDescriptor) { @@ -152,7 +168,7 @@ private boolean isFeatureFileTestDescriptor(TestDescriptor cucumberTestDescripto } private List flatListOfAllTestDescriptorChildrenWithPickleName(TestDescriptor testDescriptor, - String pickleName) { + String pickleName) { if (testDescriptor.getChildren().isEmpty()) { Optional pickleId = getPickleName(testDescriptor); if (pickleId.isPresent() && pickleName.equals(pickleId.get())) { diff --git a/impacted-test-engine/src/test/java/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.java b/impacted-test-engine/src/test/java/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.java index 9e4f78c63..e03b4a784 100644 --- a/impacted-test-engine/src/test/java/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.java +++ b/impacted-test-engine/src/test/java/com/teamscale/test_impacted/engine/executor/TestwiseCoverageCollectingExecutionListenerTest.java @@ -85,9 +85,9 @@ void testInteractionWithListenersAndCoverageApi() { assertThat(testExecutions).hasSize(2); assertThat(testExecutions).anySatisfy(testExecution -> - assertThat(testExecution.getUniformPath()).isEqualTo("MyClass/impactedTestCase()")); + assertThat(testExecution.uniformPath).isEqualTo("MyClass/impactedTestCase()")); assertThat(testExecutions).anySatisfy(testExecution -> - assertThat(testExecution.getUniformPath()).isEqualTo("MyClass/regularSkippedTestCase()")); + assertThat(testExecution.uniformPath).isEqualTo("MyClass/regularSkippedTestCase()")); } @Test @@ -125,6 +125,6 @@ void testSkipOfTestClass() { assertThat(testExecutions).hasSize(2); assertThat(testExecutions) - .allMatch(testExecution -> testExecution.getResult().equals(ETestExecutionResult.SKIPPED)); + .allMatch(testExecution -> testExecution.result.equals(ETestExecutionResult.SKIPPED)); } } \ No newline at end of file diff --git a/impacted-test-engine/src/test/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolverTest.java b/impacted-test-engine/src/test/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolverTest.java index 7ea90f0a6..288c41963 100644 --- a/impacted-test-engine/src/test/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolverTest.java +++ b/impacted-test-engine/src/test/java/com/teamscale/test_impacted/test_descriptor/CucumberPickleDescriptorResolverTest.java @@ -27,4 +27,26 @@ void escapeSlashes() { expectedByInput.forEach((input, expected) -> Assertions.assertEquals(expected, CucumberPickleDescriptorResolver.escapeSlashes(input))); } + + @Test + void testNoDuplicatedSlashesInUniformPath() { + CucumberPickleDescriptorResolver cucumberPickleDescriptorResolver = new CucumberPickleDescriptorResolver(); + LinkedHashMap expectedByInput = new LinkedHashMap<>(); + expectedByInput.put("abc", "abc"); + expectedByInput.put("ab/c", "ab/c"); + expectedByInput.put("ab//c", "ab/c"); + expectedByInput.put("ab///c", "ab/c"); + expectedByInput.put("ab\\/\\//c", "ab\\/\\//c"); + expectedByInput.put("a/", "a/"); + expectedByInput.put("a//", "a/"); + expectedByInput.put("/a", "/a"); + expectedByInput.put("//a", "/a"); + expectedByInput.put("/", "/"); + expectedByInput.put("\\/", "\\/"); + expectedByInput.put("\\", "\\"); + expectedByInput.put("\\\\", "\\\\"); + + expectedByInput.forEach((input, expected) -> Assertions.assertEquals(expected, + cucumberPickleDescriptorResolver.removeDuplicatedSlashes(input))); + } } \ No newline at end of file diff --git a/installer/build.gradle.kts b/installer/build.gradle.kts index ccbdc35a8..daa56e493 100644 --- a/installer/build.gradle.kts +++ b/installer/build.gradle.kts @@ -5,7 +5,7 @@ plugins { com.teamscale.`java-convention` com.teamscale.coverage com.teamscale.`system-test-convention` - id("org.beryx.jlink") version ("3.0.1") + id("org.beryx.jlink") version ("3.1.1") } tasks.jar { @@ -81,7 +81,7 @@ dependencies { tasks.processResources { filesMatching("**/app.properties") { filter { - it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", rootProject.ext["appVersion"].toString()) + it.replace("%APP_VERSION_TOKEN_REPLACED_DURING_BUILD%", version.toString()) } } } diff --git a/report-generator/build.gradle.kts b/report-generator/build.gradle.kts index 882e8782b..e9d3e4b12 100644 --- a/report-generator/build.gradle.kts +++ b/report-generator/build.gradle.kts @@ -3,6 +3,7 @@ plugins { com.teamscale.`java-convention` com.teamscale.coverage com.teamscale.publish + kotlin("jvm") } publishAs { diff --git a/report-generator/src/main/java/com/teamscale/report/EDuplicateClassFileBehavior.java b/report-generator/src/main/java/com/teamscale/report/EDuplicateClassFileBehavior.java deleted file mode 100644 index 9fb811ca8..000000000 --- a/report-generator/src/main/java/com/teamscale/report/EDuplicateClassFileBehavior.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.teamscale.report; - -/** - * Behavior when two non-identical class files with the same package name are found. - */ -public enum EDuplicateClassFileBehavior { - /** Completely ignores it. */ - IGNORE, - - /** Prints a warning to the logger. */ - WARN, - - /** Fails and stops further processing. */ - FAIL -} diff --git a/report-generator/src/main/java/com/teamscale/report/ReportUtils.java b/report-generator/src/main/java/com/teamscale/report/ReportUtils.java deleted file mode 100644 index c5513dd6f..000000000 --- a/report-generator/src/main/java/com/teamscale/report/ReportUtils.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.teamscale.report; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.TestDetails; -import com.teamscale.report.testwise.ETestArtifactFormat; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestwiseCoverageReport; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** Utilities for generating reports. */ -public class ReportUtils { - - /** Converts to given test list to a json report and writes it to the given file. */ - public static void writeTestListReport(File reportFile, List report) throws IOException { - writeReportToFile(reportFile, report); - } - - /** Converts to given test execution report to a json report and writes it to the given file. */ - public static void writeTestExecutionReport(File reportFile, List report) throws IOException { - writeReportToFile(reportFile, report); - } - - /** Converts to given testwise coverage report to a json report and writes it to the given file. */ - public static void writeTestwiseCoverageReport(File reportFile, TestwiseCoverageReport report) throws IOException { - writeReportToFile(reportFile, report); - } - - /** Converts to given report to a json string. For testing only. */ - public static String getTestwiseCoverageReportAsString( - TestwiseCoverageReport report) throws JsonProcessingException { - return JsonUtils.serialize(report); - } - - /** Writes the report object to the given file as json. */ - private static void writeReportToFile(File reportFile, T report) throws IOException { - File directory = reportFile.getParentFile(); - if (!directory.isDirectory() && !directory.mkdirs()) { - throw new IOException("Failed to create directory " + directory.getAbsolutePath()); - } - JsonUtils.serializeToFile(reportFile, report); - } - - /** Recursively lists all files in the given directory that match the specified extension. */ - public static List readObjects(ETestArtifactFormat format, Class clazz, - List directoriesOrFiles) throws IOException { - List files = listFiles(format, directoriesOrFiles); - ArrayList result = new ArrayList<>(); - for (File file : files) { - T[] t = JsonUtils.deserializeFile(file, clazz); - if (t != null) { - result.addAll(Arrays.asList(t)); - } - } - return result; - } - - /** Recursively lists all files of the given artifact type. */ - public static List listFiles(ETestArtifactFormat format, List directoriesOrFiles) { - List filesWithSpecifiedArtifactType = new ArrayList<>(); - for (File directoryOrFile : directoriesOrFiles) { - if (directoryOrFile.isDirectory()) { - filesWithSpecifiedArtifactType.addAll(FileSystemUtils - .listFilesRecursively(directoryOrFile, file -> fileIsOfArtifactFormat(file, format))); - } else if (fileIsOfArtifactFormat(directoryOrFile, format)) { - filesWithSpecifiedArtifactType.add(directoryOrFile); - } - } - return filesWithSpecifiedArtifactType; - } - - private static boolean fileIsOfArtifactFormat(File file, ETestArtifactFormat format) { - return file.isFile() && - file.getName().startsWith(format.filePrefix) && - FileSystemUtils.getFileExtension(file).equalsIgnoreCase(format.extension); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/CoverageFile.java b/report-generator/src/main/java/com/teamscale/report/jacoco/CoverageFile.java deleted file mode 100644 index c121a013d..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/CoverageFile.java +++ /dev/null @@ -1,133 +0,0 @@ -package com.teamscale.report.jacoco; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.util.Objects; - -import com.teamscale.client.FileSystemUtils; - -import okhttp3.MultipartBody; -import okhttp3.RequestBody; - -/** - * Represents a coverage file on disk. The main purpose is to avoid reading the - * entire file into memory as this dramatically increases the memory footprint - * of the JVM which might run out of memory because of this. - * - * The object internally holds a counter of how many references to the file are - * currently held. This allows to share the same file for multiple uploads and - * deleting it once all uploads have succeeded. Use {@link #acquireReference()} - * to make the object aware that it was passed to another uploader and - * {@link #delete()} to signal that you no longer intend to access the file. - */ -public class CoverageFile { - - private final File coverageFile; - private int referenceCounter = 0; - - public CoverageFile(File coverageFile) { - this.coverageFile = coverageFile; - } - - /** - * Marks the file as being used by an additional uploader. This ensures that the - * file is not deleted until all users have signed via {@link #delete()} that - * they no longer intend to access the file. - */ - public CoverageFile acquireReference() { - referenceCounter++; - return this; - } - - /** - * Copies the coverage File in blocks from the disk to the output stream to - * avoid having to read the entire file into memory. - */ - public void copy(OutputStream outputStream) throws IOException { - FileInputStream inputStream = new FileInputStream(coverageFile); - FileSystemUtils.copy(inputStream, outputStream); - inputStream.close(); - } - - /** - * Get the filename of the coverage file on disk without its extension - */ - public String getNameWithoutExtension() { - return FileSystemUtils.getFilenameWithoutExtension(coverageFile); - } - - /** Get the filename of the coverage file. */ - public String getName() { - return coverageFile.getName(); - } - - /** - * Delete the coverage file from disk - */ - public void delete() throws IOException { - referenceCounter--; - if (referenceCounter <= 0) { - Files.delete(coverageFile.toPath()); - } - } - - /** - * Create a {@link okhttp3.MultipartBody} form body with the contents of the - * coverage file. - */ - public RequestBody createFormRequestBody() { - return RequestBody.create(MultipartBody.FORM, new File(coverageFile.getAbsolutePath())); - } - - /** - * Get the {@link java.io.OutputStream} in order to write to the coverage file. - * - * @throws IOException - * If the file did not exist yet and could not be created - */ - public OutputStream getOutputStream() throws IOException { - try { - return new FileOutputStream(coverageFile); - } catch (IOException e) { - throw new IOException("Could not create temporary coverage file" + this + ". " - + "This is used to cache the coverage file on disk before uploading it to its final destination. " - + "This coverage is lost. Please fix the underlying issue to avoid losing coverage.", e); - } - - } - - /** - * {@inheritDoc} - */ - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - CoverageFile that = (CoverageFile) o; - return coverageFile.equals(that.coverageFile); - } - - /** - * {@inheritDoc} - */ - @Override - public int hashCode() { - return Objects.hash(coverageFile); - } - - /** - * {@inheritDoc} - */ - @Override - public String toString() { - return coverageFile.getAbsolutePath(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/EmptyReportException.java b/report-generator/src/main/java/com/teamscale/report/jacoco/EmptyReportException.java deleted file mode 100644 index 80f873def..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/EmptyReportException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.teamscale.report.jacoco; - -/** - * Exception indicating that the generated report was empty and no {@link CoverageFile} was written to disk. - */ -public class EmptyReportException extends Exception { - - public EmptyReportException(String message) { - super(message); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/FilteringAnalyzer.java b/report-generator/src/main/java/com/teamscale/report/jacoco/FilteringAnalyzer.java deleted file mode 100644 index ab6607b58..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/FilteringAnalyzer.java +++ /dev/null @@ -1,97 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.report.jacoco; - -import com.teamscale.report.util.BashFileSkippingInputStream; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.analysis.Analyzer; -import org.jacoco.core.analysis.ICoverageVisitor; -import org.jacoco.core.data.ExecutionDataStore; - -import java.io.IOException; -import java.io.InputStream; -import java.util.function.Predicate; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -/** - * {@link Analyzer} that filters the analyzed class files based on a {@link Predicate}. - */ -/* package */ public class FilteringAnalyzer extends OpenAnalyzer { - - /** The filter for the analyzed class files. */ - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - private final ILogger logger; - - public FilteringAnalyzer(ExecutionDataStore executionData, ICoverageVisitor coverageVisitor, - ClasspathWildcardIncludeFilter locationIncludeFilter, ILogger logger) { - super(executionData, coverageVisitor); - this.locationIncludeFilter = locationIncludeFilter; - this.logger = logger; - } - - /** {@inheritDoc} */ - @Override - public int analyzeAll(InputStream input, String location) throws IOException { - if (location.endsWith(".class") && !locationIncludeFilter.isIncluded(location)) { - logger.debug("Excluding class file " + location); - return 1; - } - if (location.endsWith(".jar")) { - return analyzeJar(input, location); - } - return super.analyzeAll(input, location); - } - - @Override - public void analyzeClass(final byte[] buffer, final String location) - throws IOException { - try { - analyzeClass(buffer); - } catch (final RuntimeException cause) { - if (isUnsupportedClassFile(cause)) { - logger.error(cause.getMessage() + " in " + location); - } else { - throw analyzerError(location, cause); - } - } - } - - /** - * Checks if the error indicates that the class file might be newer than what is currently supported by - * JaCoCo. The concrete error message seems to depend on the used JVM, so we only check for "Unsupported" which seems - * to be common amongst all of them. - */ - private boolean isUnsupportedClassFile(RuntimeException cause) { - return cause instanceof IllegalArgumentException && cause.getMessage() - .startsWith("Unsupported"); - } - - /** - * Copied from Analyzer.analyzeZip renamed to analyzeJar and added wrapping BashFileSkippingInputStream. - */ - protected int analyzeJar(final InputStream input, final String location) - throws IOException { - ZipInputStream zip = new ZipInputStream(new BashFileSkippingInputStream(input)); - ZipEntry entry; - int count = 0; - while ((entry = nextEntry(zip, location)) != null) { - count += analyzeAll(zip, location + "@" + entry.getName()); - } - return count; - } - - /** Copied from Analyzer.nextEntry. */ - private ZipEntry nextEntry(final ZipInputStream input, - final String location) throws IOException { - try { - return input.getNextEntry(); - } catch (final IOException e) { - throw analyzerError(location, e); - } - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.java b/report-generator/src/main/java/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.java deleted file mode 100644 index 73ca6abb3..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.teamscale.report.jacoco; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.jacoco.dump.Dump; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.analysis.CoverageBuilder; -import org.jacoco.core.analysis.IBundleCoverage; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.SessionInfo; -import org.jacoco.report.IReportVisitor; -import org.jacoco.report.xml.XMLFormatter; - -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Collections; -import java.util.List; - -/** Creates an XML report from binary execution data. */ -public class JaCoCoXmlReportGenerator { - - /** The logger. */ - private final ILogger logger; - - /** Directories and zip files that contain class files. */ - private final List codeDirectoriesOrArchives; - - /** - * Include filter to apply to all locations during class file traversal. - */ - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - - /** Whether to ignore non-identical duplicates of class files. */ - private final EDuplicateClassFileBehavior duplicateClassFileBehavior; - - /** Whether to remove uncovered classes from the report. */ - private final boolean ignoreUncoveredClasses; - - /** Part of the error message logged when validating the coverage report fails. */ - private static final String MOST_LIKELY_CAUSE_MESSAGE = "Most likely you did not configure the agent correctly." + - " Please check that the includes and excludes options are set correctly so the relevant code is included." + - " If in doubt, first include more code and then iteratively narrow the patterns down to just the relevant code." + - " If you have specified the class-dir option, please make sure it points to a directory containing the" + - " class files/jars/wars/ears/etc. for which you are trying to measure code coverage."; - - public JaCoCoXmlReportGenerator(List codeDirectoriesOrArchives, - ClasspathWildcardIncludeFilter locationIncludeFilter, - EDuplicateClassFileBehavior duplicateClassFileBehavior, - boolean ignoreUncoveredClasses, ILogger logger) { - this.codeDirectoriesOrArchives = codeDirectoriesOrArchives; - this.duplicateClassFileBehavior = duplicateClassFileBehavior; - this.locationIncludeFilter = locationIncludeFilter; - this.ignoreUncoveredClasses = ignoreUncoveredClasses; - this.logger = logger; - } - - - /** - * Creates the report and writes it to a file. - * - * @return The file object of for the converted report or null if it could not be created - */ - public CoverageFile convert(Dump dump, File filePath) throws IOException, EmptyReportException { - CoverageFile coverageFile = new CoverageFile(filePath); - convertToReport(coverageFile, dump); - return coverageFile; - } - - /** Creates the report. */ - private void convertToReport(CoverageFile coverageFile, Dump dump) throws IOException, EmptyReportException { - ExecutionDataStore mergedStore = dump.store; - IBundleCoverage bundleCoverage = analyzeStructureAndAnnotateCoverage(mergedStore); - checkForEmptyReport(bundleCoverage); - try (OutputStream outputStream = coverageFile.getOutputStream()) { - createReport(outputStream, bundleCoverage, dump.info, mergedStore); - } - } - - private void checkForEmptyReport(IBundleCoverage coverage) throws EmptyReportException { - if (coverage.getPackages().size() == 0 || coverage.getLineCounter().getTotalCount() == 0) { - throw new EmptyReportException("The generated coverage report is empty. " + MOST_LIKELY_CAUSE_MESSAGE); - } - if (coverage.getLineCounter().getCoveredCount() == 0) { - throw new EmptyReportException( - "The generated coverage report does not contain any covered source code lines. " + - MOST_LIKELY_CAUSE_MESSAGE); - } - } - - /** Creates an XML report based on the given session and coverage data. */ - private static void createReport(OutputStream output, IBundleCoverage bundleCoverage, SessionInfo sessionInfo, - ExecutionDataStore store) throws IOException { - XMLFormatter xmlFormatter = new XMLFormatter(); - IReportVisitor visitor = xmlFormatter.createVisitor(output); - - visitor.visitInfo(Collections.singletonList(sessionInfo), store.getContents()); - visitor.visitBundle(bundleCoverage, null); - visitor.visitEnd(); - } - - /** - * Analyzes the structure of the class files in {@link #codeDirectoriesOrArchives} and builds an in-memory coverage - * report with the coverage in the given store. - */ - private IBundleCoverage analyzeStructureAndAnnotateCoverage(ExecutionDataStore store) throws IOException { - CoverageBuilder coverageBuilder = new TeamscaleCoverageBuilder(this.logger, - duplicateClassFileBehavior, ignoreUncoveredClasses); - - FilteringAnalyzer analyzer = new FilteringAnalyzer(store, coverageBuilder, locationIncludeFilter, logger); - - for (File file : codeDirectoriesOrArchives) { - analyzer.analyzeAll(file); - } - - return coverageBuilder.getBundle("dummybundle"); - } - -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java b/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java index 97556dca8..b0baf36b9 100644 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java +++ b/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java @@ -46,14 +46,16 @@ * - {@link #analyzeClass(byte[])} * - {@link #analyzerError(String, Exception)} *

- * When performing an update of JaCoCo we need to check that this file is still up-to-date. + * When performing an update of JaCoCo we need to check that this file is still up to date. *

* An {@link Analyzer} instance processes a set of Java class files and * calculates coverage data for them. For each class file the result is reported - * to a given {@link ICoverageVisitor} instance. In addition the + * to a given {@link ICoverageVisitor} instance. In addition, the * {@link Analyzer} requires a {@link ExecutionDataStore} instance that holds * the execution data for the classes to analyze. The {@link Analyzer} offers * several methods to analyze classes from a variety of sources. + *

+ * CAUTION: Do not convert to Kotlin. This class has to stay in Java for future maintenance reasons! */ public class OpenAnalyzer { @@ -73,7 +75,7 @@ public class OpenAnalyzer { * class */ public OpenAnalyzer(final ExecutionDataStore executionData, - final ICoverageVisitor coverageVisitor) { + final ICoverageVisitor coverageVisitor) { this.executionData = executionData; this.coverageVisitor = coverageVisitor; this.stringPool = new StringPool(); @@ -203,17 +205,17 @@ public int analyzeAll(final InputStream input, final String location) throw analyzerError(location, e); } switch (detector.getType()) { - case ContentTypeDetector.CLASSFILE: - analyzeClass(detector.getInputStream(), location); - return 1; - case ContentTypeDetector.ZIPFILE: - return analyzeZip(detector.getInputStream(), location); - case ContentTypeDetector.GZFILE: - return analyzeGzip(detector.getInputStream(), location); - case ContentTypeDetector.PACK200FILE: - return analyzePack200(detector.getInputStream(), location); - default: - return 0; + case ContentTypeDetector.CLASSFILE: + analyzeClass(detector.getInputStream(), location); + return 1; + case ContentTypeDetector.ZIPFILE: + return analyzeZip(detector.getInputStream(), location); + case ContentTypeDetector.GZFILE: + return analyzeGzip(detector.getInputStream(), location); + case ContentTypeDetector.PACK200FILE: + return analyzePack200(detector.getInputStream(), location); + default: + return 0; } } @@ -312,4 +314,4 @@ private int analyzePack200(final InputStream input, final String location) return analyzeAll(unpackedInput, location); } -} +} \ No newline at end of file diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.java b/report-generator/src/main/java/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.java deleted file mode 100644 index 6cf802f5a..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.java +++ /dev/null @@ -1,76 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.report.jacoco; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.analysis.CoverageBuilder; -import org.jacoco.core.analysis.IBundleCoverage; -import org.jacoco.core.analysis.IClassCoverage; -import org.jacoco.core.analysis.ICounter; -import org.jacoco.core.internal.analysis.BundleCoverageImpl; - -import java.util.Collections; - -/** - * Modified {@link CoverageBuilder} can ignore non-identical duplicate classes or classes without coverage. In addition, - * coverage returned via {@link #getBundle(String)} will only return source file coverage because Teamscale does not - * need class coverage anyway. This reduces XML size by approximately half. - */ -/* package */class TeamscaleCoverageBuilder extends CoverageBuilder { - - /** The logger. */ - private final ILogger logger; - - /** How to behave if duplicate class files are encountered. */ - private final EDuplicateClassFileBehavior duplicateClassFileBehavior; - - /** Whether to ignore uncovered classes (i.e. leave them out of the report). */ - private final boolean ignoreUncoveredClasses; - - TeamscaleCoverageBuilder(ILogger logger, EDuplicateClassFileBehavior duplicateClassFileBehavior, - boolean removeUncoveredClasses) { - this.logger = logger; - this.duplicateClassFileBehavior = duplicateClassFileBehavior; - this.ignoreUncoveredClasses = removeUncoveredClasses; - } - - /** Just returns source file coverage, because Teamscale does not need class coverage. */ - @Override - public IBundleCoverage getBundle(final String name) { - return new BundleCoverageImpl(name, Collections.emptyList(), getSourceFiles()); - } - - /** {@inheritDoc} */ - @Override - public void visitCoverage(IClassCoverage coverage) { - if (ignoreUncoveredClasses && (coverage.getClassCounter().getStatus() & ICounter.FULLY_COVERED) == 0) { - return; - } - - try { - super.visitCoverage(coverage); - } catch (IllegalStateException e) { - switch (duplicateClassFileBehavior) { - case IGNORE: - return; - case WARN: - // we deliberately do not log the exception in this case as it does not provide any additional - // valuable information but confuses users into thinking there's a serious problem with the agent - // as they only see that there are stack traces in the log - logger.warn("Ignoring duplicate, non-identical class file for class " + coverage - .getName() + " compiled from source file " + coverage.getSourceFileName() + "." - + " This happens when a class with the same fully-qualified name is loaded twice but the two loaded class files are not identical." - + " A common reason for this is that the same library or shared code is included twice in your application but in two different versions." - + " The produced coverage for this class may not be accurate or may even be unusable." - + " To fix this problem, please resolve the conflict between both class files in your application."); - return; - default: - throw e; - } - } - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/jacoco/dump/Dump.java b/report-generator/src/main/java/com/teamscale/report/jacoco/dump/Dump.java deleted file mode 100644 index 09ad436f6..000000000 --- a/report-generator/src/main/java/com/teamscale/report/jacoco/dump/Dump.java +++ /dev/null @@ -1,26 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.report.jacoco.dump; - -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.SessionInfo; - -/** All data received in one dump. */ -public class Dump { - - /** The session info. */ - public final SessionInfo info; - - /** The execution data store. */ - public final ExecutionDataStore store; - - /** Constructor. */ - public Dump(SessionInfo info, ExecutionDataStore store) { - this.info = info; - this.store = store; - } - -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/ETestArtifactFormat.java b/report-generator/src/main/java/com/teamscale/report/testwise/ETestArtifactFormat.java deleted file mode 100644 index 3ef64b3e2..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/ETestArtifactFormat.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.teamscale.report.testwise; - -/** Enum of test artifacts that can be converted to a full testwise coverage report later on. */ -public enum ETestArtifactFormat { - - /** A json list of tests ({@link com.teamscale.client.TestDetails}). */ - TEST_LIST("Test List", "test-list", "json"), - - /** A json list of test executions ({@link com.teamscale.report.testwise.model.TestExecution}). */ - TEST_EXECUTION("Test Execution", "test-execution", "json"), - - /** Binary jacoco test coverage (.exec file). */ - JACOCO("Jacoco", "", "exec"), - - /** Google closure coverage files with additional uniformPath entries. */ - CLOSURE("Closure Coverage", "closure-coverage", "json"); - - /** A readable name for the report type. */ - public final String readableName; - - /** Prefix to use when writing the report to the file system. */ - public final String filePrefix; - - /** File extension of the report. */ - public final String extension; - - ETestArtifactFormat(String readableName, String filePrefix, String extension) { - this.readableName = readableName; - this.filePrefix = filePrefix; - this.extension = extension; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/TestwiseCoverageReportWriter.java b/report-generator/src/main/java/com/teamscale/report/testwise/TestwiseCoverageReportWriter.java deleted file mode 100644 index d43af754b..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/TestwiseCoverageReportWriter.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.teamscale.report.testwise; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.report.testwise.model.TestInfo; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.testwise.model.factory.TestInfoFactory; - -import java.io.File; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.util.function.Consumer; - -/** - * Writes out a {@link com.teamscale.report.testwise.model.TestwiseCoverageReport} one {@link TestInfo} after the other - * so that we do not need to keep them all in memory during the conversion. - */ -public class TestwiseCoverageReportWriter implements Consumer, AutoCloseable { - - /** Factory for converting {@link TestCoverageBuilder} objects to {@link TestInfo}s. */ - private final TestInfoFactory testInfoFactory; - - private final File outputFile; - /** After how many written tests a new file should be started. */ - private final int splitAfter; - - /** Writer instance to where the {@link com.teamscale.report.testwise.model.TestwiseCoverageReport} is written to. */ - private JsonGenerator jsonGenerator; - - /** Number of tests written to the file. */ - private int testsWritten = 0; - - /** Number of test files that have been written. */ - private int testFileCounter = 0; - - public TestwiseCoverageReportWriter(TestInfoFactory testInfoFactory, File outputFile, - int splitAfter) throws IOException { - this.testInfoFactory = testInfoFactory; - this.outputFile = outputFile; - this.splitAfter = splitAfter; - - startReport(); - } - - @Override - public void accept(TestCoverageBuilder testCoverageBuilder) { - TestInfo testInfo = testInfoFactory.createFor(testCoverageBuilder); - try { - writeTestInfo(testInfo); - } catch (IOException e) { - // Need to be wrapped in RuntimeException as Consumer does not allow to throw a checked Exception - throw new RuntimeException("Writing test info to report failed.", e); - } - } - - @Override - public void close() throws IOException { - for (TestInfo testInfo : testInfoFactory.createTestInfosWithoutCoverage()) { - writeTestInfo(testInfo); - } - endReport(); - } - - private void startReport() throws IOException { - testFileCounter++; - OutputStream outputStream = Files.newOutputStream(getOutputFile(testFileCounter).toPath()); - jsonGenerator = JsonUtils.createFactory().createGenerator(outputStream); - jsonGenerator.setPrettyPrinter(new DefaultPrettyPrinter()); - jsonGenerator.writeStartObject(); - jsonGenerator.writeFieldName("tests"); - jsonGenerator.writeStartArray(); - } - - private File getOutputFile(int testFileCounter) { - String name = this.outputFile.getName(); - name = StringUtils.stripSuffix(name, ".json"); - name = name + "-" + testFileCounter + ".json"; - return new File(this.outputFile.getParent(), name); - } - - private void writeTestInfo(TestInfo testInfo) throws IOException { - if (testsWritten >= splitAfter) { - endReport(); - testsWritten = 0; - startReport(); - } - jsonGenerator.writeObject(testInfo); - testsWritten++; - } - - private void endReport() throws IOException { - jsonGenerator.writeEndArray(); - jsonGenerator.writeEndObject(); - jsonGenerator.close(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.java deleted file mode 100644 index e57d5b02b..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.teamscale.report.testwise.jacoco; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.jacoco.dump.Dump; -import com.teamscale.report.testwise.jacoco.cache.AnalyzerCache; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.jacoco.cache.ProbesCache; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.data.ExecutionData; -import org.jacoco.core.data.ExecutionDataStore; - -import java.io.File; -import java.io.IOException; -import java.util.Collection; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -/** - * Helper class for analyzing class files, reading execution data and converting them to coverage data. - */ -class CachingExecutionDataReader { - - private final ILogger logger; - private final Collection classesDirectories; - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - private final EDuplicateClassFileBehavior duplicateClassFileBehavior; - private ProbesCache probesCache; - - public CachingExecutionDataReader(ILogger logger, Collection classesDirectories, - ClasspathWildcardIncludeFilter locationIncludeFilter, - EDuplicateClassFileBehavior duplicateClassFileBehavior) { - this.logger = logger; - this.classesDirectories = classesDirectories; - this.locationIncludeFilter = locationIncludeFilter; - this.duplicateClassFileBehavior = duplicateClassFileBehavior; - } - - /** - * Analyzes the class/jar/war/... files and creates a lookup of which probes belong to which method. - */ - public void analyzeClassDirs() { - if (probesCache == null) { - probesCache = new ProbesCache(logger, duplicateClassFileBehavior); - } - if (classesDirectories.isEmpty()) { - logger.warn("No class directories found for caching."); - return; - } - AnalyzerCache analyzer = new AnalyzerCache(probesCache, locationIncludeFilter, logger); - int classCount = 0; - for (File classDir : classesDirectories) { - if (classDir.exists()) { - try { - classCount += analyzer.analyzeAll(classDir); - } catch (IOException e) { - logger.error("Failed to analyze class files in " + classDir + "! " + - "Maybe the folder contains incompatible class files. " + - "Coverage for class files in this folder will be ignored.", e); - } - } - } - if (classCount == 0) { - String directoryList = classesDirectories.stream().map(File::getPath).collect(Collectors.joining(",")); - logger.error("No class files found in the given directories! " + directoryList); - } else if (probesCache.isEmpty()) { - String directoryList = classesDirectories.stream().map(File::getPath).collect(Collectors.joining(",")); - logger.error( - "None of the " + classCount + " class files found in the given directories match the configured include/exclude patterns! " + directoryList); - } - } - - /** - * Converts the given store to coverage data. The coverage will only contain line range coverage information. - */ - public DumpConsumer buildCoverageConsumer(ClasspathWildcardIncludeFilter locationIncludeFilter, - Consumer nextConsumer) { - return new DumpConsumer(logger, locationIncludeFilter, nextConsumer); - } - - /** - * Consumer of {@link Dump} objects. Converts them to {@link TestCoverageBuilder} and passes them to the - * nextConsumer. - */ - public class DumpConsumer implements Consumer { - - /** The logger. */ - private final ILogger logger; - - /** The location include filter to be applied on the profiled classes. */ - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - - /** Consumer that should be called with the newly built TestCoverageBuilder. */ - private final Consumer nextConsumer; - - private DumpConsumer(ILogger logger, ClasspathWildcardIncludeFilter locationIncludeFilter, - Consumer nextConsumer) { - this.logger = logger; - this.locationIncludeFilter = locationIncludeFilter; - this.nextConsumer = nextConsumer; - } - - @Override - public void accept(Dump dump) { - String testId = dump.info.getId(); - if (testId.isEmpty()) { - // Ignore intermediate coverage that does not belong to any specific test - logger.debug("Found a session with empty name! This could indicate that coverage is dumped also for " + - "coverage in between tests or that the given test name was empty!"); - return; - } - try { - TestCoverageBuilder testCoverage = buildCoverage(testId, dump.store, locationIncludeFilter); - nextConsumer.accept(testCoverage); - } catch (CoverageGenerationException e) { - logger.error("Failed to generate coverage for test " + testId + "! Skipping to the next test.", e); - } - } - - /** - * Converts the given store to coverage data. The coverage will only contain line range coverage information. - */ - private TestCoverageBuilder buildCoverage(String testId, ExecutionDataStore executionDataStore, - ClasspathWildcardIncludeFilter locationIncludeFilter) throws CoverageGenerationException { - TestCoverageBuilder testCoverage = new TestCoverageBuilder(testId); - for (ExecutionData executionData : executionDataStore.getContents()) { - testCoverage.add(probesCache.getCoverage(executionData, locationIncludeFilter)); - } - probesCache.flushLogger(); - return testCoverage; - } - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.java deleted file mode 100644 index 971557ca4..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.teamscale.report.testwise.jacoco; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.jacoco.dump.Dump; -import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException; -import com.teamscale.report.testwise.model.TestwiseCoverage; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.data.ExecutionData; -import org.jacoco.core.data.ExecutionDataReader; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.IExecutionDataVisitor; -import org.jacoco.core.data.ISessionInfoVisitor; -import org.jacoco.core.data.SessionInfo; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.function.Consumer; - -/** - * Creates an XML report for an execution data store. The report is grouped by session. - *

- * The class files under test must be compiled with debug information otherwise no coverage will be collected. - */ -public class JaCoCoTestwiseReportGenerator { - - /** The execution data reader and converter. */ - private final CachingExecutionDataReader executionDataReader; - - /** The filter for the analyzed class files. */ - private final ClasspathWildcardIncludeFilter locationIncludeFilter; - - /** - * Create a new generator with a collection of class directories. - * - * @param codeDirectoriesOrArchives Root directory that contains the projects class files. - * @param locationIncludeFilter Filter for class files - * @param logger The logger - */ - public JaCoCoTestwiseReportGenerator(Collection codeDirectoriesOrArchives, - ClasspathWildcardIncludeFilter locationIncludeFilter, - EDuplicateClassFileBehavior duplicateClassFileBehavior, - ILogger logger) { - this.locationIncludeFilter = locationIncludeFilter; - this.executionDataReader = new CachingExecutionDataReader(logger, codeDirectoriesOrArchives, - locationIncludeFilter, duplicateClassFileBehavior); - updateClassDirCache(); - } - - /** - * Updates the probe cache of the {@link ExecutionDataReader}. - */ - public void updateClassDirCache() { - executionDataReader.analyzeClassDirs(); - } - - /** Converts the given dumps to a report. */ - public TestwiseCoverage convert(File executionDataFile) throws IOException, CoverageGenerationException { - TestwiseCoverage testwiseCoverage = new TestwiseCoverage(); - CachingExecutionDataReader.DumpConsumer dumpConsumer = executionDataReader - .buildCoverageConsumer(locationIncludeFilter, testwiseCoverage::add); - readAndConsumeDumps(executionDataFile, dumpConsumer); - return testwiseCoverage; - } - - /** Converts the given dump to a report. */ - public TestCoverageBuilder convert(Dump dump) throws CoverageGenerationException { - List list = new ArrayList<>(); - CachingExecutionDataReader.DumpConsumer dumpConsumer = executionDataReader - .buildCoverageConsumer(locationIncludeFilter, list::add); - dumpConsumer.accept(dump); - if (list.size() == 1) { - return list.get(0); - } else { - return null; - } - } - - /** Converts the given dumps to a report. */ - public void convertAndConsume(File executionDataFile, - Consumer consumer) throws IOException { - CachingExecutionDataReader.DumpConsumer dumpConsumer = executionDataReader - .buildCoverageConsumer(locationIncludeFilter, consumer); - readAndConsumeDumps(executionDataFile, dumpConsumer); - } - - /** Reads the dumps from the given *.exec file. */ - private void readAndConsumeDumps(File executionDataFile, Consumer dumpConsumer) throws IOException { - try (InputStream input = new BufferedInputStream(new FileInputStream(executionDataFile))) { - ExecutionDataReader executionDataReader = new ExecutionDataReader(input); - DumpCallback dumpCallback = new DumpCallback(dumpConsumer); - executionDataReader.setExecutionDataVisitor(dumpCallback); - executionDataReader.setSessionInfoVisitor(dumpCallback); - executionDataReader.read(); - // Ensure that the last read dump is also consumed - dumpCallback.processDump(); - } - } - - /** Collects execution information per session and passes it to the consumer . */ - private static class DumpCallback implements IExecutionDataVisitor, ISessionInfoVisitor { - - /** The dump that is currently being read. */ - private Dump currentDump = null; - - /** The store to which coverage is currently written to. */ - private ExecutionDataStore store; - - /** The consumer to pass {@link Dump}s to. */ - private final Consumer dumpConsumer; - - private DumpCallback(Consumer dumpConsumer) { - this.dumpConsumer = dumpConsumer; - } - - @Override - public void visitSessionInfo(SessionInfo info) { - processDump(); - store = new ExecutionDataStore(); - currentDump = new Dump(info, store); - } - - @Override - public void visitClassExecution(ExecutionData data) { - store.put(data); - } - - private void processDump() { - if (currentDump != null) { - dumpConsumer.accept(currentDump); - currentDump = null; - } - } - } -} \ No newline at end of file diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.java deleted file mode 100644 index 6e67f2597..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.teamscale.report.testwise.jacoco.cache; - -import com.teamscale.report.jacoco.FilteringAnalyzer; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.analysis.Analyzer; -import org.jacoco.core.internal.analysis.CachingClassAnalyzer; -import org.jacoco.core.internal.analysis.ClassCoverageImpl; -import org.jacoco.core.internal.analysis.StringPool; -import org.jacoco.core.internal.data.CRC64; -import org.jacoco.core.internal.flow.ClassProbesAdapter; -import org.jacoco.core.internal.instr.InstrSupport; -import org.objectweb.asm.ClassReader; -import org.objectweb.asm.ClassVisitor; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Paths; - -/** - * An {@link AnalyzerCache} instance processes a set of Java class/jar/war/... files and builds a {@link - * ClassCoverageLookup} for each of the classes. - *

- * For every class that gets found {@link #analyzeClass(byte[])} is called. A class is identified by its class ID which - * is a CRC64 checksum of the classfile. We process each class with {@link CachingClassAnalyzer} to fill a {@link - * ClassCoverageLookup}. - */ -public class AnalyzerCache extends FilteringAnalyzer { - - /** The probes cache. */ - private final ProbesCache probesCache; - - - private final StringPool stringPool = new StringPool(); - - /** Creates a new analyzer filling the given cache. */ - public AnalyzerCache(ProbesCache probesCache, ClasspathWildcardIncludeFilter locationIncludeFilter, - ILogger logger) { - super(null, null, locationIncludeFilter, logger); - this.probesCache = probesCache; - } - - /** - * Analyses the given class. Instead of the original implementation in {@link Analyzer#analyzeClass(byte[])} we - * don't use concrete execution data, but instead build a probe cache to speed up repeated lookups. - */ - @Override - protected void analyzeClass(final byte[] source) { - long classId = CRC64.classId(source); - if (probesCache.containsClassId(classId)) { - return; - } - final ClassReader reader = InstrSupport.classReaderFor(source); - ClassCoverageLookup classCoverageLookup = probesCache.createClass(classId, reader.getClassName()); - - // Dummy class coverage object that allows us to subclass ClassAnalyzer with CachingClassAnalyzer and reuse its - // IFilterContext implementation - final ClassCoverageImpl dummyClassCoverage = new ClassCoverageImpl(reader.getClassName(), - classId, false); - - CachingClassAnalyzer classAnalyzer = new CachingClassAnalyzer(classCoverageLookup, dummyClassCoverage, - stringPool); - final ClassVisitor visitor = new ClassProbesAdapter(classAnalyzer, false); - reader.accept(visitor, 0); - } - - /** - * Adds caching for jar files to the analyze jar functionality. - */ - @Override - protected int analyzeJar(final InputStream input, final String location) throws IOException { - long jarId = CRC64.classId(Files.readAllBytes(Paths.get(location))); - int probesCountForJarId = probesCache.countForJarId(jarId); - if (probesCountForJarId != 0) { - return probesCountForJarId; - } - int count = super.analyzeJar(input, location); - probesCache.addJarId(jarId, count); - return count; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.java deleted file mode 100644 index 2675b3309..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.teamscale.report.testwise.jacoco.cache; - -import com.teamscale.client.StringUtils; -import com.teamscale.report.testwise.model.builder.FileCoverageBuilder; -import com.teamscale.report.util.ILogger; -import com.teamscale.report.util.SortedIntList; -import org.jacoco.core.data.ExecutionData; - -import java.util.ArrayList; -import java.util.List; - -/** - * Holds information about a class' probes and to which line ranges they refer. - *

- *

    - *
  • Create an instance of this class for every analyzed java class. - *
  • Set the file name of the java source file from which the class has been created. - *
  • Then call {@link #addProbe(int, SortedIntList)} for all probes and lines that belong to that probe. - *
  • Afterwards call {@link #getFileCoverage(ExecutionData, ILogger)} to transform probes ({@link - * ExecutionData}) for this class into covered lines ({@link FileCoverageBuilder}). - *
- */ -public class ClassCoverageLookup { - - /** Fully qualified name of the class (with / as separators). */ - private String className; - - /** Name of the java source file. */ - private String sourceFileName; - - /** - * Mapping from probe IDs to sets of covered lines. The index in this list corresponds to the probe ID. - */ - private final List probes = new ArrayList<>(); - - /** - * Constructor. - * - * @param className Classname as stored in the bytecode e.g. com/company/Example - */ - ClassCoverageLookup(String className) { - this.className = className; - } - - /** Sets the file name of the currently analyzed class (without path). */ - public void setSourceFileName(String sourceFileName) { - this.sourceFileName = sourceFileName; - } - - /** Adjusts the size of the probes list to the total probes count. */ - public void setTotalProbeCount(int count) { - ensureArraySize(count - 1); - } - - /** Adds the probe with the given id to the method. */ - public void addProbe(int probeId, SortedIntList lines) { - ensureArraySize(probeId); - probes.set(probeId, lines); - } - - /** - * Ensures that the probes list is big enough to allow access to the given index. Intermediate list entries are - * filled with null. - */ - private void ensureArraySize(int index) { - while (index >= probes.size()) { - probes.add(null); - } - } - - /** - * Generates {@link FileCoverageBuilder} from an {@link ExecutionData}. {@link ExecutionData} holds coverage of - * exactly one class (whereby inner classes are a separate class). This method returns a {@link FileCoverageBuilder} - * object which is later merged with the {@link FileCoverageBuilder} of other classes that reside in the same file. - */ - public FileCoverageBuilder getFileCoverage(ExecutionData executionData, - ILogger logger) throws CoverageGenerationException { - boolean[] executedProbes = executionData.getProbes(); - - if (checkProbeInvariant(executedProbes)) { - throw new CoverageGenerationException("Probe lookup does not match with actual probe size for " + - sourceFileName + " " + className + " (" + probes.size() + " vs " + executedProbes.length + ")! " + - "This is a bug in the profiler tooling. Please report it back to CQSE."); - } - if (sourceFileName == null) { - logger.warn( - "No source file name found for class " + className + "! This class was probably not compiled with " + - "debug information enabled!"); - return null; - } - - // we model the default package as the empty string - String packageName = ""; - if (className.contains("/")) { - packageName = StringUtils.removeLastPart(className, '/'); - } - final FileCoverageBuilder fileCoverage = new FileCoverageBuilder(packageName, sourceFileName); - fillFileCoverage(fileCoverage, executedProbes, logger); - - return fileCoverage; - } - - private void fillFileCoverage(FileCoverageBuilder fileCoverage, boolean[] executedProbes, ILogger logger) { - for (int i = 0; i < probes.size(); i++) { - SortedIntList coveredLines = probes.get(i); - if (!executedProbes[i]) { - continue; - } - // coveredLines is null if the probe is outside of a method - // Happens e.g. for methods generated by Lombok - if (coveredLines == null) { - logger.info(sourceFileName + " " + className + " did contain a covered probe " + i + "(of " + - executedProbes.length + ") that could not be " + - "matched to any method. This could be a bug in the profiler tooling. Please report it back " + - "to CQSE."); - continue; - } - if (coveredLines.isEmpty()) { - logger.debug( - sourceFileName + " " + className + " did contain a method with no line information. " + - "Does the class contain debug information?"); - continue; - } - fileCoverage.addLines(coveredLines); - } - } - - /** Checks that the executed probes is not smaller than the cached probes. */ - private boolean checkProbeInvariant(boolean[] executedProbes) { - return probes.size() > executedProbes.length; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.java deleted file mode 100644 index b8fe6ecc1..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.teamscale.report.testwise.jacoco.cache; - -import com.teamscale.report.util.ILogger; - -import java.util.HashSet; -import java.util.Set; - -/** - * Coordinates logging of missing class files to ensure the warnings are only emitted once and not for every individual - * test. - */ -/* package */ class ClassNotFoundLogger { - - /** The logger. */ - private final ILogger logger; - - /** Missing classes that will be logged when {@link #flush()} is called. */ - private final Set classesToBeLogged = new HashSet<>(); - - /** Classes that have already been reported as missing. */ - private final Set alreadyLoggedClasses = new HashSet<>(); - - /** Constructor */ - /* package */ ClassNotFoundLogger(ILogger logger) { - this.logger = logger; - } - - /** Saves the given class to be logged later on. Ensures that the class is only logged once. */ - /* package */ void log(String fullyQualifiedClassName) { - if (!alreadyLoggedClasses.contains(fullyQualifiedClassName)) { - classesToBeLogged.add(fullyQualifiedClassName); - } - } - - /** Writes a summary of the missing class files to the logger. */ - /* package */ void flush() { - if (classesToBeLogged.isEmpty()) { - return; - } - - logger.warn( - "Found coverage for " + classesToBeLogged - .size() + " classes that were not provided. Either you did not provide " + - "all relevant class files or you did not adjust the include/exclude filters on the agent to exclude " + - "coverage from irrelevant code. The classes are:" - ); - for (String fullyQualifiedClassName : classesToBeLogged) { - logger.warn(" - " + fullyQualifiedClassName); - } - alreadyLoggedClasses.addAll(classesToBeLogged); - classesToBeLogged.clear(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.java deleted file mode 100644 index fa975c191..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.teamscale.report.testwise.jacoco.cache; - -/** - * Exception thrown during coverage generation. - */ -public class CoverageGenerationException extends Exception { - - /** Constructor. */ - public CoverageGenerationException(String message) { - super(message); - } - -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ProbesCache.java b/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ProbesCache.java deleted file mode 100644 index 603fc1337..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/jacoco/cache/ProbesCache.java +++ /dev/null @@ -1,113 +0,0 @@ -package com.teamscale.report.testwise.jacoco.cache; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.testwise.model.builder.FileCoverageBuilder; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import org.jacoco.core.data.ExecutionData; -import org.jacoco.report.JavaNames; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -/** - * Holds {@link ClassCoverageLookup}s for all analyzed classes. - */ -public class ProbesCache { - - /** The logger. */ - private final ILogger logger; - - /** A mapping from class ID (CRC64 of the class file) to {@link ClassCoverageLookup}. */ - private final HashMap classCoverageLookups = new HashMap<>(); - - /** Holds all fully-qualified class names that are already contained in the cache. */ - private final Set containedClasses = new HashSet<>(); - - private final Map containedJars = new HashMap<>(); - - /** Whether to ignore non-identical duplicates of class files. */ - private final EDuplicateClassFileBehavior duplicateClassFileBehavior; - - private final ClassNotFoundLogger classNotFoundLogger; - - /** Constructor. */ - public ProbesCache(ILogger logger, EDuplicateClassFileBehavior duplicateClassFileBehavior) { - this.logger = logger; - this.classNotFoundLogger = new ClassNotFoundLogger(logger); - this.duplicateClassFileBehavior = duplicateClassFileBehavior; - } - - /** Adds a new class entry to the cache and returns its {@link ClassCoverageLookup}. */ - public ClassCoverageLookup createClass(long classId, String className) { - if (containedClasses.contains(className)) { - if (duplicateClassFileBehavior != EDuplicateClassFileBehavior.IGNORE) { - logger.warn("Non-identical class file for class " + className + "." - + " This happens when a class with the same fully-qualified name is loaded twice but the two loaded class files are not identical." - + " A common reason for this is that the same library or shared code is included twice in your application but in two different versions." - + " The produced coverage for this class may not be accurate or may even be unusable." - + " To fix this problem, please resolve the conflict between both class files in your application."); - } - if (duplicateClassFileBehavior == EDuplicateClassFileBehavior.FAIL) { - throw new IllegalStateException( - "Found non-identical class file for class " + className + ". See logs for more details."); - } - } - containedClasses.add(className); - ClassCoverageLookup classCoverageLookup = new ClassCoverageLookup(className); - classCoverageLookups.put(classId, classCoverageLookup); - return classCoverageLookup; - } - - /** Returns whether a class with the given class ID has already been analyzed. */ - public boolean containsClassId(long classId) { - return classCoverageLookups.containsKey(classId); - } - - /** - * Returns the number of found class files in a cached jar file. Otherwise 0. - */ - public int countForJarId(long jarId) { - return containedJars.getOrDefault(jarId, 0); - } - - /** - * Adds a jar id along with the count of class files found in the jar. - */ - public void addJarId(long jarId, int count) { - containedJars.put(jarId, count); - } - - /** - * Converts the given {@link ExecutionData} to {@link FileCoverageBuilder} using the cached lookups or null if the - * class file of this class has not been included in the analysis or was not covered. - */ - public FileCoverageBuilder getCoverage(ExecutionData executionData, - ClasspathWildcardIncludeFilter locationIncludeFilter) throws CoverageGenerationException { - long classId = executionData.getId(); - if (!containsClassId(classId)) { - String fullyQualifiedClassName = new JavaNames().getQualifiedClassName(executionData.getName()); - if (locationIncludeFilter.isIncluded(fullyQualifiedClassName + ".class")) { - classNotFoundLogger.log(fullyQualifiedClassName); - } - return null; - } - if (!executionData.hasHits()) { - return null; - } - - return classCoverageLookups.get(classId).getFileCoverage(executionData, logger); - } - - /** Returns true if the cache does not contain coverage for any class. */ - public boolean isEmpty() { - return classCoverageLookups.isEmpty(); - } - - /** Prints a the collected class not found messages. */ - public void flushLogger() { - classNotFoundLogger.flush(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/ERevisionType.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/ERevisionType.java deleted file mode 100644 index 01417d1b5..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/ERevisionType.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.teamscale.report.testwise.model; - -/** Type of revision information. */ -public enum 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/java/com/teamscale/report/testwise/model/FileCoverage.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/FileCoverage.java deleted file mode 100644 index 40ba4c93b..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/FileCoverage.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** Holds coverage of a single file. */ -public class FileCoverage { - - /** The name of the file. */ - public final String fileName; - - /** A list of line ranges that have been covered. */ - public final String coveredLines; - - @JsonCreator - public FileCoverage(@JsonProperty("fileName") String fileName, @JsonProperty("coveredLines") String coveredLines) { - this.fileName = fileName; - this.coveredLines = coveredLines; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/LineRange.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/LineRange.java deleted file mode 100644 index b280100b3..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/LineRange.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.teamscale.report.testwise.model; - -/** Holds a line range with start and end (both inclusive and 1-based). */ -public class LineRange { - - /** The start line (1-based). */ - private int start; - - /** The end line (1-based). */ - private int end; - - /** Constructor. */ - public LineRange(int start, int end) { - this.start = start; - this.end = end; - } - - /** @see #end */ - public int getEnd() { - return end; - } - - /** @see #end */ - public void setEnd(int end) { - this.end = end; - } - - /** - * Returns the line range as used in the XML report. - * A range is returned as e.g. 2-5 or simply 3 if the start and end are equal. - */ - public String toReportString() { - if (start == end) { - return String.valueOf(start); - } else { - return start + "-" + end; - } - } - - @Override - public String toString() { - return toReportString(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/PathCoverage.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/PathCoverage.java deleted file mode 100644 index 1886c7c9b..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/PathCoverage.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -/** Container for {@link FileCoverage}s of the same path. */ -public class PathCoverage { - - /** File system path. */ - private final String path; - - /** Files with coverage. */ - private final List files; - - @JsonCreator - public PathCoverage(@JsonProperty("path") String path, @JsonProperty("files") List files) { - this.path = path; - this.files = files; - } - - public String getPath() { - return path; - } - - public List getFiles() { - return files; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/RevisionInfo.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/RevisionInfo.java deleted file mode 100644 index b272ec1b6..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/RevisionInfo.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.teamscale.client.CommitDescriptor; - -import java.io.Serializable; - -/** Revision information necessary for uploading reports to Teamscale. */ -public class RevisionInfo implements Serializable { - - private static final long serialVersionUID = 1L; - - /** The type of revision information. */ - private final ERevisionType type; - - /** The value. Either a commit descriptor or a source control revision, depending on {@link #type}. */ - private final String value; - - @JsonCreator - public RevisionInfo(@JsonProperty("type") ERevisionType type, @JsonProperty("value") String value) { - this.type = type; - this.value = value; - } - - /** Constructor for Commit. */ - public RevisionInfo(CommitDescriptor commit) { - type = ERevisionType.COMMIT; - value = commit.toString(); - } - - /** Constructor for Revision. */ - public RevisionInfo(String revision) { - type = ERevisionType.REVISION; - value = revision; - } - - /** - * Constructor in case you have both fields, and either may be null. If both are set, the commit wins. If both are - * null, {@link #type} will be {@link ERevisionType#REVISION} and {@link #value} will be null. - */ - public RevisionInfo(CommitDescriptor commit, String revision) { - if (commit == null) { - type = ERevisionType.REVISION; - value = revision; - } else { - type = ERevisionType.COMMIT; - value = commit.toString(); - } - } - - public ERevisionType getType() { - return type; - } - - public String getValue() { - return value; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestExecution.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/TestExecution.java deleted file mode 100644 index 6f6409177..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestExecution.java +++ /dev/null @@ -1,129 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2005-2018 The ConQAT Project | -| | -| Licensed under the Apache License, Version 2.0 (the "License"); | -| you may not use this file except in compliance with the License. | -| You may obtain a copy of the License at | -| | -| http://www.apache.org/licenses/LICENSE-2.0 | -| | -| Unless required by applicable law or agreed to in writing, software | -| distributed under the License is distributed on an "AS IS" BASIS, | -| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | -| See the License for the specific language governing permissions and | -| limitations under the License. | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.io.Serializable; - -/** Representation of a single test (method) execution. */ -public class TestExecution implements Serializable { - - private static final long serialVersionUID = 1L; - - /** - * The uniform path of the test (method) that was executed. This is an absolute (i.e. hierarchical) reference which - * identifies the test uniquely in the scope of the Teamscale project. It may (but is not required to) correspond to - * the path of some automated test case source code known to Teamscale. If the test was parameterized, this path is - * expected to reflect the parameter in some manner. - */ - private String uniformPath; - - /** Duration of the execution in milliseconds. */ - @Deprecated - private long durationMillis; - - /** Duration of the execution in seconds. */ - @JsonProperty("duration") - @JsonAlias("durationSeconds") - private Double duration; - - /** The actual execution result state. */ - private ETestExecutionResult result; - - /** - * Optional message given for test failures (normally contains a stack trace). May be {@code null}. - */ - private String message; - - /** - * Needed for Jackson deserialization. - */ - @JsonCreator - public TestExecution() { - // Needed for Jackson - } - - public TestExecution(String name, long durationMillis, ETestExecutionResult result) { - this(name, durationMillis, result, null); - } - - public TestExecution(String name, long durationMillis, ETestExecutionResult result, String message) { - this.uniformPath = name; - this.durationMillis = durationMillis; - this.result = result; - this.message = message; - } - - /** @see #durationMillis */ - public double getDurationSeconds() { - if (duration != null) { - return duration; - } else { - return durationMillis / 1000.0; - } - } - - /** @see #result */ - public ETestExecutionResult getResult() { - return result; - } - - /** @see #message */ - public String getMessage() { - return message; - } - - /** @see #uniformPath */ - public String getUniformPath() { - return uniformPath; - } - - /** @see #uniformPath */ - public void setUniformPath(String uniformPath) { - this.uniformPath = uniformPath; - } - - /** @see #durationMillis */ - public void setDurationMillis(long durationMillis) { - this.durationMillis = durationMillis; - } - - /** @see #result */ - public void setResult(ETestExecutionResult result) { - this.result = result; - } - - /** @see #message */ - public void setMessage(String message) { - this.message = message; - } - - @Override - public String toString() { - return "TestExecution{" + - "uniformPath='" + uniformPath + '\'' + - ", durationMillis=" + durationMillis + - ", duration=" + duration + - ", result=" + result + - ", message='" + message + '\'' + - '}'; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestInfo.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/TestInfo.java deleted file mode 100644 index 8648662ec..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestInfo.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.ArrayList; -import java.util.List; - -/** Generic container of all information about a specific test as written to the report. */ -@SuppressWarnings({"FieldCanBeLocal", "WeakerAccess"}) -public class TestInfo { - - /** Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. */ - public final String uniformPath; - - /** - * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a Base - * class will have the sourcePath pointing to the Base class which contains the actual implementation whereas - * uniformPath will contain the class name of the most specific subclass, from where it was actually executed. - */ - public final String sourcePath; - - /** - * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the - * specification or similar. - */ - public final String content; - - /** Duration of the execution in seconds. */ - public final Double duration; - - /** The actual execution result state. */ - public final ETestExecutionResult result; - - /** - * Optional message given for test failures (normally contains a stack trace). May be {@code null}. - */ - public final String message; - - /** All paths that the test did cover. */ - public final List paths = new ArrayList<>(); - - @JsonCreator - public TestInfo(@JsonProperty("uniformPath") String uniformPath, @JsonProperty("sourcePath") String sourcePath, - @JsonProperty("content") String content, @JsonProperty("duration") Double duration, - @JsonProperty("result") ETestExecutionResult result, - @JsonProperty("message") String message) { - this.uniformPath = uniformPath; - this.sourcePath = sourcePath; - this.content = content; - this.duration = duration; - this.result = result; - this.message = message; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverage.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverage.java deleted file mode 100644 index 94e786a44..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverage.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -/** Container for coverage produced by multiple tests. */ -public class TestwiseCoverage { - - /** A mapping from test ID to {@link TestCoverageBuilder}. */ - private final Map tests = new HashMap<>(); - - /** - * Adds the {@link TestCoverageBuilder} to the map. - * If there is already a test with the same ID the coverage is merged. - */ - public void add(TestCoverageBuilder coverage) { - if (coverage == null || coverage.isEmpty()) { - return; - } - if (tests.containsKey(coverage.getUniformPath())) { - TestCoverageBuilder testCoverage = tests.get(coverage.getUniformPath()); - testCoverage.addAll(coverage.getFiles()); - } else { - tests.put(coverage.getUniformPath(), coverage); - } - } - - /** - * Merges the given {@link TestwiseCoverage} with this one. - */ - public void add(TestwiseCoverage testwiseCoverage) { - if (testwiseCoverage == null) { - return; - } - for (TestCoverageBuilder value : testwiseCoverage.tests.values()) { - this.add(value); - } - } - - public Collection getTests() { - return tests.values(); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverageReport.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverageReport.java deleted file mode 100644 index 1ec2f2e73..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/TestwiseCoverageReport.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.teamscale.report.testwise.model; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.ArrayList; -import java.util.List; - -/** Container for coverage produced by multiple tests. */ -public class TestwiseCoverageReport { - - /** - * If set to `true` the set of tests contained in the report don't represent the full set of tests within a - * partition. These tests are added or updated in Teamscale, but no tests or executable units that are missing in - * the report will be deleted. - */ - public final boolean partial; - - /** The tests contained in the report. */ - public final List tests = new ArrayList<>(); - - @JsonCreator - public TestwiseCoverageReport(@JsonProperty("partial") boolean partial) { - this.partial = partial; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.java deleted file mode 100644 index 0782f89d4..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.report.testwise.model.FileCoverage; -import com.teamscale.report.testwise.model.LineRange; -import com.teamscale.report.util.SortedIntList; - -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -/** Holds coverage of a single file. */ -public class FileCoverageBuilder { - - /** The file system path of the file not including the file itself. */ - private final String path; - - /** The name of the file. */ - private final String fileName; - - /** - * A list of line numbers that have been covered. Using a set here is too memory intensive. - */ - private final SortedIntList coveredLines = new SortedIntList(); - - /** Constructor. */ - public FileCoverageBuilder(String path, String fileName) { - this.path = path; - this.fileName = fileName; - } - - /** @see #fileName */ - public String getFileName() { - return fileName; - } - - /** @see #path */ - public String getPath() { - return path; - } - - /** Adds a line as covered. */ - public void addLine(int line) { - coveredLines.add(line); - } - - /** Adds a line range as covered. */ - public void addLineRange(int start, int end) { - for (int i = start; i <= end; i++) { - coveredLines.add(i); - } - } - - /** Adds set of lines as covered. */ - public void addLines(SortedIntList range) { - coveredLines.addAll(range); - } - - /** Merges the list of ranges into the current list. */ - public void merge(FileCoverageBuilder other) { - if (!other.fileName.equals(fileName) || !other.path.equals(path)) { - throw new AssertionError("Cannot merge coverage of two different files! This is a bug!"); - } - coveredLines.addAll(other.coveredLines); - } - - /** - * Merges all neighboring line numbers to ranges. E.g. a list of [[1-5],[3-7],[8-10],[12-14]] becomes - * [[1-10],[12-14]] - */ - public static List compactifyToRanges(SortedIntList lines) { - if (lines.size() == 0) { - return new ArrayList<>(); - } - - int firstLine = lines.get(0); - LineRange currentRange = new LineRange(firstLine, firstLine); - - List compactifiedRanges = new ArrayList<>(); - compactifiedRanges.add(currentRange); - - for (int i = 0; i < lines.size(); i++) { - int currentLine = lines.get(i); - if (currentRange.getEnd() == currentLine || currentRange.getEnd() == currentLine - 1) { - currentRange.setEnd(currentLine); - } else { - currentRange = new LineRange(currentLine, currentLine); - compactifiedRanges.add(currentRange); - } - } - return compactifiedRanges; - } - - /** - * Returns a compact string representation of the covered lines. Continuous line ranges are merged to ranges and - * sorted. Individual ranges are separated by commas. E.g. 1-5,7,9-11. - */ - public String computeCompactifiedRangesAsString() { - List coveredRanges = compactifyToRanges(coveredLines); - return coveredRanges.stream().map(LineRange::toReportString).collect(Collectors.joining(",")); - } - - /** Returns true if there is no coverage for the file yet. */ - public boolean isEmpty() { - return coveredLines.size() == 0; - } - - /** Builds the {@link FileCoverage} object, which is serialized into the report. */ - public FileCoverage build() { - return new FileCoverage(fileName, computeCompactifiedRangesAsString()); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.java deleted file mode 100644 index 8da3d78c3..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.report.testwise.model.FileCoverage; -import com.teamscale.report.testwise.model.PathCoverage; - -import java.util.Collection; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.toList; - -/** Container for {@link FileCoverageBuilder}s of the same path. */ -public class PathCoverageBuilder { - - /** File system path. */ - private final String path; - - /** Mapping from file names to {@link FileCoverageBuilder}. */ - private final Map fileCoverageList = new HashMap<>(); - - /** Constructor. */ - public PathCoverageBuilder(String path) { - this.path = path; - } - - /** @see #path */ - public String getPath() { - return path; - } - - /** - * Adds the given {@link FileCoverageBuilder} to the container. - * If coverage for the same file already exists it gets merged. - */ - public void add(FileCoverageBuilder fileCoverage) { - if (fileCoverageList.containsKey(fileCoverage.getFileName())) { - FileCoverageBuilder existingFile = fileCoverageList.get(fileCoverage.getFileName()); - existingFile.merge(fileCoverage); - } else { - fileCoverageList.put(fileCoverage.getFileName(), fileCoverage); - } - } - - /** Returns a collection of {@link FileCoverageBuilder}s associated with this path. */ - public Collection getFiles() { - return fileCoverageList.values(); - } - - /** Builds a {@link PathCoverage} object. */ - public PathCoverage build() { - List files = fileCoverageList.values().stream() - .sorted(Comparator.comparing(FileCoverageBuilder::getFileName)) - .map(FileCoverageBuilder::build).collect(toList()); - return new PathCoverage(path, files); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.java deleted file mode 100644 index b8eedce72..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.report.testwise.model.PathCoverage; - -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static java.util.stream.Collectors.toList; - -/** Generic holder of test coverage of a single test based on line-ranges. */ -public class TestCoverageBuilder { - - /** The uniformPath of the test (see TEST_IMPACT_ANALYSIS_DOC.md for more information). */ - private final String uniformPath; - - /** Mapping from path names to all files on this path. */ - private final Map pathCoverageList = new HashMap<>(); - - /** Constructor. */ - public TestCoverageBuilder(String uniformPath) { - this.uniformPath = uniformPath; - } - - /** @see #uniformPath */ - public String getUniformPath() { - return uniformPath; - } - - /** Returns a collection of {@link PathCoverageBuilder}s associated with the test. */ - public List getPaths() { - return pathCoverageList.values().stream().sorted(Comparator.comparing(PathCoverageBuilder::getPath)) - .map(PathCoverageBuilder::build).collect(toList()); - } - - /** Adds the {@link FileCoverageBuilder} to into the map, but filters out file coverage that is null or empty. */ - public void add(FileCoverageBuilder fileCoverage) { - if (fileCoverage == null || fileCoverage.isEmpty() - || fileCoverage.getFileName() == null || fileCoverage.getPath() == null) { - return; - } - PathCoverageBuilder pathCoverage = pathCoverageList - .computeIfAbsent(fileCoverage.getPath(), PathCoverageBuilder::new); - pathCoverage.add(fileCoverage); - } - - /** Adds the {@link FileCoverageBuilder}s into the map, but filters out empty ones. */ - public void addAll(List fileCoverageList) { - for (FileCoverageBuilder fileCoverage : fileCoverageList) { - add(fileCoverage); - } - } - - /** Returns all {@link FileCoverageBuilder}s stored for the test. */ - public List getFiles() { - return pathCoverageList.values().stream() - .flatMap(path -> path.getFiles().stream()) - .collect(toList()); - } - - /** Returns true if there is no coverage for the test yet. */ - public boolean isEmpty() { - return pathCoverageList.isEmpty(); - } - -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestInfoBuilder.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestInfoBuilder.java deleted file mode 100644 index 6a4bd08c0..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestInfoBuilder.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.client.TestDetails; -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; - -/** Generic container of all information about a specific test including details, execution info and coverage. */ -public class TestInfoBuilder { - - /** Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. */ - public final String uniformPath; - - /** - * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a base - * class will have the sourcePath pointing to the base class which contains the actual implementation whereas - * uniformPath will contain the the class name of the most specific subclass, from where it was actually executed. - */ - private String sourcePath = null; - - /** - * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the - * specification or similar. - */ - private String content = null; - - /** Duration of the execution in milliseconds. */ - private Double durationSeconds = null; - - /** The actual execution result state. */ - private ETestExecutionResult result; - - /** - * Optional message given for test failures (normally contains a stack trace). May be {@code null}. - */ - private String message; - - /** Coverage generated by this test. */ - private TestCoverageBuilder coverage; - - /** Constructor. */ - /* package */ - public TestInfoBuilder(String uniformPath) { - this.uniformPath = uniformPath; - } - - /** @see #uniformPath */ - public String getUniformPath() { - return uniformPath; - } - - /** Returns true if there is no coverage for the test yet. */ - public boolean isEmpty() { - return coverage.isEmpty(); - } - - /** Sets the test details fields. */ - public void setDetails(TestDetails details) { - if (details != null) { - sourcePath = details.sourcePath; - content = details.content; - } - } - - /** Sets the test execution fields. */ - public void setExecution(TestExecution execution) { - if (execution != null) { - durationSeconds = execution.getDurationSeconds(); - result = execution.getResult(); - message = execution.getMessage(); - } - } - - /** @see #coverage */ - public void setCoverage(TestCoverageBuilder coverage) { - this.coverage = coverage; - } - - /** Builds a {@link TestInfo} object of the data in this container. */ - public TestInfo build() { - TestInfo testInfo = new TestInfo(uniformPath, sourcePath, content, durationSeconds, result, message); - if (coverage != null) { - testInfo.paths.addAll(coverage.getPaths()); - } - return testInfo; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.java deleted file mode 100644 index ae221bc7d..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.client.TestDetails; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import com.teamscale.report.testwise.model.TestwiseCoverageReport; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** Container for coverage produced by multiple tests. */ -public class TestwiseCoverageReportBuilder { - - /** A mapping from test ID to {@link TestCoverageBuilder}. */ - private final Map tests = new HashMap<>(); - - /** - * Adds the {@link TestCoverageBuilder} to the map. If there is already a test with the same ID the coverage is - * merged. - */ - public static TestwiseCoverageReport createFrom( - Collection testDetailsList, - Collection testCoverage, - Collection testExecutions, - boolean partial - ) { - TestwiseCoverageReportBuilder report = new TestwiseCoverageReportBuilder(); - for (TestDetails testDetails : testDetailsList) { - TestInfoBuilder container = new TestInfoBuilder(testDetails.uniformPath); - container.setDetails(testDetails); - report.tests.put(testDetails.uniformPath, container); - } - for (TestCoverageBuilder coverage : testCoverage) { - TestInfoBuilder container = resolveUniformPath(report, coverage.getUniformPath()); - if (container == null) { - continue; - } - container.setCoverage(coverage); - } - for (TestExecution testExecution : testExecutions) { - TestInfoBuilder container = resolveUniformPath(report, testExecution.getUniformPath()); - if (container == null) { - continue; - } - container.setExecution(testExecution); - } - return report.build(partial); - } - - private static TestInfoBuilder resolveUniformPath(TestwiseCoverageReportBuilder report, String uniformPath) { - TestInfoBuilder container = report.tests.get(uniformPath); - if (container != null) { - return container; - } - String shortenedUniformPath = stripParameterizedTestArguments(uniformPath); - TestInfoBuilder testInfoBuilder = report.tests.get(shortenedUniformPath); - if (testInfoBuilder == null) { - System.err.println("No container found for test '" + uniformPath + "'!"); - } - return testInfoBuilder; - } - - /** - * Removes parameterized test arguments from the given uniform path. - */ - public static String stripParameterizedTestArguments(String uniformPath) { - return uniformPath.replaceFirst("(.*\\))\\[.*]", "$1"); - } - - private TestwiseCoverageReport build(boolean partial) { - TestwiseCoverageReport report = new TestwiseCoverageReport(partial); - List testInfoBuilders = new ArrayList<>(tests.values()); - testInfoBuilders.sort(Comparator.comparing(TestInfoBuilder::getUniformPath)); - for (TestInfoBuilder testInfoBuilder : testInfoBuilders) { - TestInfo testInfo = testInfoBuilder.build(); - if (testInfo == null) { - System.err.println("No coverage for test '" + testInfoBuilder.getUniformPath() + "'"); - continue; - } - report.tests.add(testInfo); - } - return report; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/testwise/model/factory/TestInfoFactory.java b/report-generator/src/main/java/com/teamscale/report/testwise/model/factory/TestInfoFactory.java deleted file mode 100644 index 0895630a9..000000000 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/factory/TestInfoFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.teamscale.report.testwise.model.factory; - -import com.teamscale.client.TestDetails; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.testwise.model.builder.TestInfoBuilder; -import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * Factory class for converting {@link TestCoverageBuilder} to {@link TestInfo}s while augmenting them with information - * from test details and test executions. - */ -public class TestInfoFactory { - - /** Maps uniform paths to test details. */ - private Map testDetailsMap = new HashMap<>(); - - /** Maps uniform paths to test executions. */ - private Map testExecutionsMap = new HashMap<>(); - - /** Holds all uniform paths for tests that have been written to the outputFile. */ - private final Set processedTestUniformPaths = new HashSet<>(); - - public TestInfoFactory(List testDetails, List testExecutions) { - for (TestDetails testDetail : testDetails) { - testDetailsMap.put(testDetail.uniformPath, testDetail); - } - for (TestExecution testExecution : testExecutions) { - testExecutionsMap.put(testExecution.getUniformPath(), testExecution); - } - } - - /** - * Converts the given {@link TestCoverageBuilder} to a {@link TestInfo} using the internally stored test details and - * test executions. - */ - public TestInfo createFor(TestCoverageBuilder testCoverageBuilder) { - String resolvedUniformPath = resolveUniformPath(testCoverageBuilder.getUniformPath()); - processedTestUniformPaths.add(resolvedUniformPath); - - TestInfoBuilder container = new TestInfoBuilder(resolvedUniformPath); - container.setCoverage(testCoverageBuilder); - TestDetails testDetails = testDetailsMap.get(resolvedUniformPath); - if (testDetails == null) { - System.err.println("No test details found for " + resolvedUniformPath); - } - container.setDetails(testDetails); - TestExecution execution = testExecutionsMap.get(resolvedUniformPath); - if (execution == null) { - System.err.println("No test execution found for " + resolvedUniformPath); - } - container.setExecution(execution); - return container.build(); - } - - /** Returns {@link TestInfo}s for all tests that have not been used yet in {@link #createFor(TestCoverageBuilder)}. */ - public List createTestInfosWithoutCoverage() { - ArrayList results = new ArrayList<>(); - for (TestDetails testDetails : testDetailsMap.values()) { - if (!processedTestUniformPaths.contains(testDetails.uniformPath)) { - TestInfoBuilder testInfo = new TestInfoBuilder(testDetails.uniformPath); - testInfo.setDetails(testDetails); - testInfo.setExecution(testExecutionsMap.get(testDetails.uniformPath)); - results.add(testInfo.build()); - processedTestUniformPaths.add(testDetails.uniformPath); - } - } - for (TestExecution testExecution : testExecutionsMap.values()) { - if (!processedTestUniformPaths.contains(testExecution.getUniformPath())) { - System.err.println("Test " + testExecution.getUniformPath() + " was executed but no coverage was found. " + - "Please make sure that you did provide all relevant exec files and that the test IDs passed to " + - "the agent match the ones from the provided test execution list."); - processedTestUniformPaths.add(testExecution.getUniformPath()); - } - } - return results; - } - - /** - * Strips parameterized test arguments when the full path given in the coverage file cannot be found in the test - * details. - */ - private String resolveUniformPath(String originalUniformPath) { - String uniformPath = originalUniformPath; - TestDetails testDetails = testDetailsMap.get(uniformPath); - if (testDetails == null) { - uniformPath = TestwiseCoverageReportBuilder - .stripParameterizedTestArguments(uniformPath); - } - return uniformPath; - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/AntPatternIncludeFilter.java b/report-generator/src/main/java/com/teamscale/report/util/AntPatternIncludeFilter.java deleted file mode 100644 index 309e22750..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/AntPatternIncludeFilter.java +++ /dev/null @@ -1,57 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.report.util; - -import com.teamscale.client.AntPatternUtils; -import com.teamscale.client.FileSystemUtils; - -import java.util.List; -import java.util.function.Predicate; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * Applies ANT include and exclude patterns to paths. - */ -public class AntPatternIncludeFilter implements Predicate { - - /** The include filters. Empty means include everything. */ - private final List locationIncludeFilters; - - /** The exclude filters. Empty means exclude nothing. */ - private final List locationExcludeFilters; - - /** Constructor. */ - public AntPatternIncludeFilter(List locationIncludeFilters, List locationExcludeFilters) { - this.locationIncludeFilters = locationIncludeFilters.stream().map( - filter -> AntPatternUtils.convertPattern(filter, false)).collect(Collectors.toList()); - this.locationExcludeFilters = locationExcludeFilters.stream().map( - filter -> AntPatternUtils.convertPattern(filter, false)).collect(Collectors.toList()); - } - - /** {@inheritDoc} */ - @Override - public boolean test(String path) { - return !isFiltered(FileSystemUtils.normalizeSeparators(path)); - } - - /** - * Returns true if the given class file location (normalized to forward slashes as path separators) - * should not be analyzed. - *

- * Exclude filters overrule include filters. - */ - private boolean isFiltered(String location) { - // first check includes - if (!locationIncludeFilters.isEmpty() - && locationIncludeFilters.stream().noneMatch(filter -> filter.matcher(location).matches())) { - return true; - } - // only if they match, check excludes - return locationExcludeFilters.stream().anyMatch(filter -> filter.matcher(location).matches()); - } - -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/BashFileSkippingInputStream.java b/report-generator/src/main/java/com/teamscale/report/util/BashFileSkippingInputStream.java deleted file mode 100644 index 59c9102d4..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/BashFileSkippingInputStream.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.teamscale.report.util; - -import java.io.BufferedInputStream; -import java.io.FilterInputStream; -import java.io.IOException; -import java.io.InputStream; - -/** - * Handles executable spring-boot jar files that prepend a bash file to the beginning of the ZIP file to make it - * directly executable without "java -jar my.jar". We just skip the bash file until we find the zip file header. - */ -public class BashFileSkippingInputStream extends FilterInputStream { - - /** - * Wraps the given input stream in a BufferedInputStream and consumes all bytes until a zip file header is found. - */ - public BashFileSkippingInputStream(InputStream in) throws IOException { - super(new BufferedInputStream(in)); - consumeUntilZipHeader(); - } - - /** - * Reads the stream until the zip file header "50 4B 03 04" is found or EOF is reached. After calling the method the - * read pointer points to the first byte of the zip file header. - */ - private void consumeUntilZipHeader() throws IOException { - byte[] buffer = new byte[8192]; - in.mark(buffer.length); - int count = in.read(buffer, 0, buffer.length); - while (count > 0) { - for (int i = 0; i < count - 3; i++) { - if (buffer[i] == 0x50 && buffer[i + 1] == 0x4B && buffer[i + 2] == 0x03 && buffer[i + 3] == 0x04) { - in.reset(); - in.skip(i); - return; - } - } - - // Reset mark to 3 bytes before the end of the previously read buffer end to - // also detect a zip header when it spans over two buffers - in.reset(); - in.skip(buffer.length - 3); - in.mark(buffer.length); - count = in.read(buffer, 0, buffer.length); - } - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/ClasspathWildcardIncludeFilter.java b/report-generator/src/main/java/com/teamscale/report/util/ClasspathWildcardIncludeFilter.java deleted file mode 100644 index 56d9480c5..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/ClasspathWildcardIncludeFilter.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.teamscale.report.util; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import org.jacoco.core.runtime.WildcardMatcher; -import org.jacoco.report.JavaNames; - -/*** - * Tests given class file paths against call name patterns. - * E.g. "/some/file/path/test.jar@my/package/Test.class" matches "my/package/*" or "my/package/Test" - */ -public class ClasspathWildcardIncludeFilter { - - /** - * Include patterns to apply during JaCoCo's traversal of class files. If null then everything is included. - */ - private WildcardMatcher locationIncludeFilters = null; - - /** - * Exclude patterns to apply during JaCoCo's traversal of class files. If null then nothing is excluded. - */ - private WildcardMatcher locationExcludeFilters = null; - - /** - * Constructor. - * - * @param locationIncludeFilters Colon separated list of wildcard include patterns for fully qualified class names - * or null for no includes. See {@link WildcardMatcher} for the pattern syntax. - * @param locationExcludeFilters Colon separated list of wildcard exclude patterns for fully qualified class names - * or null for no excludes.See {@link WildcardMatcher} for the pattern syntax. - */ - public ClasspathWildcardIncludeFilter(String locationIncludeFilters, String locationExcludeFilters) { - if (locationIncludeFilters != null && !locationIncludeFilters.isEmpty()) { - this.locationIncludeFilters = new WildcardMatcher(locationIncludeFilters); - } - if (locationExcludeFilters != null && !locationExcludeFilters.isEmpty()) { - this.locationExcludeFilters = new WildcardMatcher(locationExcludeFilters); - } - } - - /** - * Tests if the given file path (e.g. "/some/file/path/test.jar@my/package/Test.class" or "org/mypackage/MyClass" - */ - public boolean isIncluded(String path) { - String className = getClassName(path); - // first check includes - if (locationIncludeFilters != null && !locationIncludeFilters.matches(className)) { - return false; - } - // if they match, check excludes - return locationExcludeFilters == null || !locationExcludeFilters.matches(className); - } - - /** - * 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". - */ - /* package */ - static String getClassName(String path) { - String[] parts = FileSystemUtils.normalizeSeparators(path).split("@"); - if (parts.length == 0) { - return ""; - } - - String pathInsideJar = parts[parts.length - 1]; - if (path.toLowerCase().endsWith(".class")) { - pathInsideJar = StringUtils.removeLastPart(pathInsideJar, '.'); - } - return new JavaNames().getQualifiedClassName(pathInsideJar); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/CommandLineLogger.java b/report-generator/src/main/java/com/teamscale/report/util/CommandLineLogger.java deleted file mode 100644 index fe112bf8b..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/CommandLineLogger.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.teamscale.report.util; - -/** Logger that prints all output to the console. */ -public class CommandLineLogger implements ILogger { - - @Override - public void debug(String message) { - System.out.println(message); - } - - @Override - public void info(String message) { - System.out.println(message); - } - - @Override - public void warn(String message) { - System.err.println(message); - } - - @Override - public void warn(String message, Throwable throwable) { - System.err.println(message); - if (throwable != null) { - throwable.printStackTrace(); - } - } - - @Override - public void error(Throwable throwable) { - throwable.printStackTrace(); - } - - @Override - public void error(String message, Throwable throwable) { - System.err.println(message); - if (throwable != null) { - throwable.printStackTrace(); - } - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/ILogger.java b/report-generator/src/main/java/com/teamscale/report/util/ILogger.java deleted file mode 100644 index b4d23c3c3..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/ILogger.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.teamscale.report.util; - -/** - * Minimal logging interface. - *

- * We use this to work around some strange problems when using log4j from the Teamscale Gradle plugin. - */ -public interface ILogger { - - /** Logs at debug level. */ - void debug(String message); - - /** Logs at info level. */ - void info(String message); - - /** Logs at warning level. */ - void warn(String message); - - /** Logs at warning level. The given {@link Throwable} may be null. */ - void warn(String message, Throwable throwable); - - /** Logs at error level. */ - void error(Throwable throwable); - - /** Logs at error level. The given {@link Throwable} may be null. */ - void error(String message, Throwable throwable); - - /** Logs at error level. */ - default void error(String message) { - error(message, null); - } -} diff --git a/report-generator/src/main/java/com/teamscale/report/util/SortedIntList.java b/report-generator/src/main/java/com/teamscale/report/util/SortedIntList.java deleted file mode 100644 index afa4a7ef8..000000000 --- a/report-generator/src/main/java/com/teamscale/report/util/SortedIntList.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.teamscale.report.util; - -/** - * Performant implementation of a deduplicated sorted integer list that assumes that insertions mainly happen at the end - * and that input is already sorted. - */ -public class SortedIntList { - - /** - * The list of values in sorted order and without duplicates. The list might be bigger than the number of elements. - */ - protected int[] list; - - /** The number of actual elements in the list. */ - private int count; - - public SortedIntList() { - list = new int[64]; - } - - /** Adds the given value to the list at the correct location, ignoring duplicates. */ - public boolean add(int value) { - int high = count; - int low = 0; - - if (isEmpty()) { - list[0] = value; - count = 1; - return true; - } - - // Perform binary search to find target location - do { - int p = (low + high) >>> 1; - if (value < list[p]) { - high = p; - } else if (value == list[p]) { - // Element already exists in the list - return false; - } else { - low = p + 1; - } - } while (low < high); - - if (count == list.length) { - int[] n = new int[list.length * 2]; - System.arraycopy(list, 0, n, 0, count); - list = n; - } - - if (low < count) { - System.arraycopy(list, low, list, low + 1, count - low); - } - list[low] = value; - count++; - return true; - } - - /** Inserts all values from the given list, ignoring duplicates. */ - public void addAll(SortedIntList input) { - for (int i = 0; i < input.size(); i++) { - add(input.get(i)); - } - } - - /** Returns the size of the list. */ - public int size() { - return count; - } - - /** Returns whether the list is empty. */ - public boolean isEmpty() { - return count == 0; - } - - /** Returns the i-th element of the list. */ - public int get(int i) { - return list[i]; - } -} \ No newline at end of file diff --git a/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingClassAnalyzer.java b/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingClassAnalyzer.java deleted file mode 100644 index 406b0877b..000000000 --- a/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingClassAnalyzer.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.jacoco.core.internal.analysis; - -import com.teamscale.report.testwise.jacoco.cache.ClassCoverageLookup; -import org.jacoco.core.internal.flow.MethodProbesVisitor; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.tree.MethodNode; - -/** - * Analyzes a class to reconstruct probe information. - *

- * A probe lookup holds for a single class which probe belongs to which lines. The actual filling of the - * {@link ClassCoverageLookup} happens in {@link CachingInstructionsBuilder}. - */ -public class CachingClassAnalyzer extends ClassAnalyzer { - - /** The cache, which contains a probe lookups for the current class. */ - private final ClassCoverageLookup classCoverageLookup; - - /** - * Creates a new analyzer that builds coverage data for a class. - * - * @param classCoverageLookup cache for the class' probes - * @param coverage coverage node for the analyzed class data - * @param stringPool shared pool to minimize the number of {@link String} instances - */ - public CachingClassAnalyzer(ClassCoverageLookup classCoverageLookup, ClassCoverageImpl coverage, StringPool stringPool) { - super(coverage, null, stringPool); - this.classCoverageLookup = classCoverageLookup; - } - - @Override - public void visitSource(String source, String debug) { - super.visitSource(source, debug); - classCoverageLookup.setSourceFileName(source); - } - - @Override - public MethodProbesVisitor visitMethod(final int access, final String name, - final String desc, final String signature, final String[] exceptions) { - final CachingInstructionsBuilder builder = new CachingInstructionsBuilder(classCoverageLookup); - - return new MethodAnalyzer(builder) { - - @Override - public void accept(final MethodNode methodNode, - final MethodVisitor methodVisitor) { - super.accept(methodNode, methodVisitor); - builder.fillCache(); - } - }; - } - - @Override - public void visitTotalProbeCount(final int count) { - classCoverageLookup.setTotalProbeCount(count); - } -} diff --git a/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingInstructionsBuilder.java b/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingInstructionsBuilder.java index 4701cbadd..21c8fda4c 100644 --- a/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingInstructionsBuilder.java +++ b/report-generator/src/main/java/org/jacoco/core/internal/analysis/CachingInstructionsBuilder.java @@ -1,7 +1,7 @@ package org.jacoco.core.internal.analysis; import com.teamscale.report.testwise.jacoco.cache.ClassCoverageLookup; -import com.teamscale.report.util.SortedIntList; +import com.teamscale.report.util.CompactLines; import org.jacoco.core.analysis.ISourceNode; import org.jacoco.core.internal.flow.LabelInfo; import org.objectweb.asm.Label; @@ -29,6 +29,8 @@ *

* When updating JaCoCo make a diff of the previous {@link org.jacoco.core.internal.analysis.InstructionsBuilder} * implementation and the new implementation and update this class accordingly. + *

+ * CAUTION: Do not convert to Kotlin. This class has to stay in Java for future maintenance reasons! */ public class CachingInstructionsBuilder extends InstructionsBuilder { @@ -170,7 +172,7 @@ public void fillCache() { // We need this because JaCoCo does not insert a probe after every line. for (CoveredProbe coveredProbe : coveredProbes) { Instruction instruction = coveredProbe.instruction; - SortedIntList coveredLines = new SortedIntList(); + CompactLines coveredLines = new CompactLines(); while (instruction != null) { if (instruction.getLine() != -1) { // Only add the line number if one is associated with the instruction. @@ -231,4 +233,4 @@ void wire() { } -} +} \ No newline at end of file diff --git a/report-generator/src/main/kotlin/com/teamscale/report/EDuplicateClassFileBehavior.kt b/report-generator/src/main/kotlin/com/teamscale/report/EDuplicateClassFileBehavior.kt new file mode 100644 index 000000000..9b21854b0 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/EDuplicateClassFileBehavior.kt @@ -0,0 +1,15 @@ +package com.teamscale.report + +/** + * Behavior when two non-identical class files with the same package name are found. + */ +enum class EDuplicateClassFileBehavior { + /** Completely ignores it. */ + IGNORE, + + /** Prints a warning to the logger. */ + WARN, + + /** Fails and stops further processing. */ + FAIL +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt b/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt new file mode 100644 index 000000000..808e949a4 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt @@ -0,0 +1,87 @@ +package com.teamscale.report + +import com.fasterxml.jackson.core.JsonProcessingException +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.JsonUtils +import com.teamscale.client.TestDetails +import com.teamscale.report.testwise.ETestArtifactFormat +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestwiseCoverageReport +import java.io.File +import java.io.IOException +import java.util.* + +/** Utilities for generating reports. */ +object ReportUtils { + /** Converts to given test list to a json report and writes it to the given file. */ + @Throws(IOException::class) + @JvmStatic + fun writeTestListReport(reportFile: File, report: List) { + writeReportToFile(reportFile, report) + } + + /** Converts to given test execution report to a json report and writes it to the given file. */ + @Throws(IOException::class) + @JvmStatic + fun writeTestExecutionReport(reportFile: File, report: List) { + writeReportToFile(reportFile, report) + } + + /** Converts to given testwise coverage report to a json report and writes it to the given file. */ + @Throws(IOException::class) + fun writeTestwiseCoverageReport(reportFile: File, report: TestwiseCoverageReport) { + writeReportToFile(reportFile, report) + } + + /** Converts to given report to a json string. For testing only. */ + @JvmStatic + @Throws(JsonProcessingException::class) + fun getTestwiseCoverageReportAsString( + report: TestwiseCoverageReport + ) = JsonUtils.serialize(report) + + /** Writes the report object to the given file as json. */ + @Throws(IOException::class) + private fun writeReportToFile(reportFile: File, report: T) { + val directory = reportFile.getParentFile() + if (!directory.isDirectory() && !directory.mkdirs()) { + throw IOException("Failed to create directory " + directory.absolutePath) + } + JsonUtils.serializeToFile(reportFile, report) + } + + /** Recursively lists all files in the given directory that match the specified extension. */ + @Throws(IOException::class) + @JvmStatic + fun readObjects( + format: ETestArtifactFormat, + clazz: Class>, + directoriesOrFiles: List + ) = listFiles(format, directoriesOrFiles) + .map { JsonUtils.deserializeFile(it, clazz) } + .flatMap { listOf(*it) } + + /** Recursively lists all files of the given artifact type. */ + @JvmStatic + fun listFiles( + format: ETestArtifactFormat, + directoriesOrFiles: List + ) = directoriesOrFiles.flatMap { directoryOrFile -> + when { + directoryOrFile.isDirectory() -> { + directoryOrFile.walkTopDown().filter { it.isOfArtifactFormat(format) }.toList() + } + + directoryOrFile.isOfArtifactFormat(format) -> { + listOf(directoryOrFile) + } + + else -> emptyList() + } + } + + private fun File.isOfArtifactFormat(format: ETestArtifactFormat) = + isFile() && + getName().startsWith(format.filePrefix) && + FileSystemUtils.getFileExtension(this).equals(format.extension, ignoreCase = true) +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/CoverageFile.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/CoverageFile.kt new file mode 100644 index 000000000..1e8754175 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/CoverageFile.kt @@ -0,0 +1,95 @@ +package com.teamscale.report.jacoco + +import okhttp3.MultipartBody +import okhttp3.RequestBody +import java.io.* +import java.util.* + +/** + * Represents a coverage file on disk. The main purpose is to avoid reading the + * entire file into memory as this dramatically increases the memory footprint + * of the JVM which might run out of memory because of this. + * + * The object internally holds a counter of how many references to the file are + * currently held. This allows to share the same file for multiple uploads and + * deleting it once all uploads have succeeded. Use [.acquireReference] + * to make the object aware that it was passed to another uploader and + * [.delete] to signal that you no longer intend to access the file. + */ +data class CoverageFile(private val coverageFile: File) { + private var referenceCounter = 0 + + /** + * Marks the file as being used by an additional uploader. This ensures that the + * file is not deleted until all users have signed via [.delete] that + * they no longer intend to access the file. + */ + fun acquireReference(): CoverageFile { + referenceCounter++ + return this + } + + /** + * Copies the coverage File in blocks from the disk to the output stream to + * avoid having to read the entire file into memory. + */ + @Throws(IOException::class) + fun copyStream(outputStream: OutputStream) { + coverageFile.inputStream().use { input -> + input.copyTo(outputStream) + } + } + + /** + * Get the filename of the coverage file on disk without its extension + */ + val nameWithoutExtension: String + get() = coverageFile.nameWithoutExtension + + /** Get the filename of the coverage file. */ + val name: String + get() = coverageFile.name + + /** + * Delete the coverage file from disk + */ + @Throws(IOException::class) + fun delete() { + referenceCounter-- + if (referenceCounter <= 0) { + coverageFile.delete() + } + } + + /** + * Create a [okhttp3.MultipartBody] form body with the contents of the + * coverage file. + */ + fun createFormRequestBody(): RequestBody = + RequestBody.create(MultipartBody.FORM, coverageFile) + + /** + * Get the [java.io.OutputStream] in order to write to the coverage file. + * + * @throws IOException + * If the file did not exist yet and could not be created + */ + @get:Throws(IOException::class) + val outputStream: OutputStream + get() { + return runCatching { + coverageFile.outputStream() + }.getOrElse { + throw IOException( + ("Could not create temporary coverage file" + this + ". " + + "This is used to cache the coverage file on disk before uploading it to its final destination. " + + "This coverage is lost. Please fix the underlying issue to avoid losing coverage."), it + ) + } + } + + /** + * {@inheritDoc} + */ + override fun toString(): String = coverageFile.absolutePath +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/EmptyReportException.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/EmptyReportException.kt new file mode 100644 index 000000000..efcf4ba15 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/EmptyReportException.kt @@ -0,0 +1,6 @@ +package com.teamscale.report.jacoco + +/** + * Exception indicating that the generated report was empty and no [CoverageFile] was written to disk. + */ +class EmptyReportException(message: String) : Exception(message) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt new file mode 100644 index 000000000..7a12e7dbe --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/FilteringAnalyzer.kt @@ -0,0 +1,88 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2018 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.report.jacoco + +import com.teamscale.report.util.BashFileSkippingInputStream +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.analysis.ICoverageVisitor +import org.jacoco.core.data.ExecutionDataStore +import java.io.IOException +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * [org.jacoco.core.analysis.Analyzer] that filters the analyzed class files based on a given predicate. + * + * @param executionData The execution data store. + * @param coverageVisitor The coverage visitor. + * @param locationIncludeFilter The filter for the analyzed class files. + * @param logger The logger. + */ +open class FilteringAnalyzer( + executionData: ExecutionDataStore?, + coverageVisitor: ICoverageVisitor?, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + private val logger: ILogger +) : OpenAnalyzer(executionData, coverageVisitor) { + /** {@inheritDoc} */ + @Throws(IOException::class) + override fun analyzeAll(input: InputStream, location: String): Int { + if (location.endsWith(".class") && !locationIncludeFilter.isIncluded(location)) { + logger.debug("Excluding class file $location") + return 1 + } + if (location.endsWith(".jar")) { + return analyzeJar(input, location) + } + return super.analyzeAll(input, location) + } + + @Throws(IOException::class) + override fun analyzeClass(buffer: ByteArray, location: String) { + try { + analyzeClass(buffer) + } catch (cause: RuntimeException) { + if (cause.isUnsupportedClassFile()) { + logger.error(cause.message + " in " + location) + } else { + throw analyzerError(location, cause) + } + } + } + + /** + * Checks if the error indicates that the class file might be newer than what is currently supported by + * JaCoCo. The concrete error message seems to depend on the used JVM, so we only check for "Unsupported" which seems + * to be common amongst all of them. + */ + private fun RuntimeException.isUnsupportedClassFile() = + this is IllegalArgumentException && message?.startsWith("Unsupported") == true + + /** + * Copied from [org.jacoco.core.analysis.Analyzer.analyzeZip] renamed to analyzeJar + * and added wrapping [BashFileSkippingInputStream]. + */ + @Throws(IOException::class) + protected open fun analyzeJar(input: InputStream, location: String): Int { + ZipInputStream(BashFileSkippingInputStream(input)).use { zip -> + return generateSequence { zip.nextEntry(location) } + .map { entry -> analyzeAll(zip, "$location@${entry.name}") } + .sum() + } + } + + /** Copied from [org.jacoco.core.analysis.Analyzer.nextEntry]. */ + @Throws(IOException::class) + private fun ZipInputStream.nextEntry(location: String): ZipEntry? { + try { + return nextEntry + } catch (e: IOException) { + throw analyzerError(location, e) + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt new file mode 100644 index 000000000..ea3406698 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt @@ -0,0 +1,106 @@ +package com.teamscale.report.jacoco + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.jacoco.dump.Dump +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.analysis.IBundleCoverage +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.SessionInfo +import org.jacoco.report.xml.XMLFormatter +import java.io.File +import java.io.IOException +import java.io.OutputStream + +/** + * Creates an XML report from binary execution data. + * + * @param codeDirectoriesOrArchives Directories and zip files that contain class files. + * @param locationIncludeFilter Include filter to apply to all locations during class file traversal. + * @param duplicateClassFileBehavior Whether to ignore non-identical duplicates of class files. + * @param ignoreUncoveredClasses Whether to remove uncovered classes from the report. + * @param logger The logger. + */ +class JaCoCoXmlReportGenerator( + private val codeDirectoriesOrArchives: List, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + private val duplicateClassFileBehavior: EDuplicateClassFileBehavior, + private val ignoreUncoveredClasses: Boolean, + private val logger: ILogger +) { + /** + * Creates the report and writes it to a file. + * + * @return The file object of for the converted report or null if it could not be created + */ + @Throws(IOException::class, EmptyReportException::class) + fun convert(dump: Dump, filePath: File): CoverageFile { + val coverageFile = CoverageFile(filePath) + convertToReport(coverageFile, dump) + return coverageFile + } + + /** Creates the report. */ + @Throws(IOException::class, EmptyReportException::class) + private fun convertToReport(coverageFile: CoverageFile, dump: Dump) { + val mergedStore = dump.store + analyzeStructureAndAnnotateCoverage(mergedStore).apply { + checkForEmptyReport() + coverageFile.outputStream.use { outputStream -> + createReport(outputStream, this, dump.info, mergedStore) + } + } + } + + @Throws(EmptyReportException::class) + private fun IBundleCoverage.checkForEmptyReport() { + if (packages.isEmpty() || lineCounter.totalCount == 0) { + throw EmptyReportException("The generated coverage report is empty. $MOST_LIKELY_CAUSE_MESSAGE") + } + if (lineCounter.coveredCount == 0) { + throw EmptyReportException("The generated coverage report does not contain any covered source code lines. $MOST_LIKELY_CAUSE_MESSAGE") + } + } + + /** + * Analyzes the structure of the class files in [.codeDirectoriesOrArchives] and builds an in-memory coverage + * report with the coverage in the given store. + */ + @Throws(IOException::class) + private fun analyzeStructureAndAnnotateCoverage(store: ExecutionDataStore): IBundleCoverage { + val coverageBuilder = TeamscaleCoverageBuilder( + logger, duplicateClassFileBehavior, ignoreUncoveredClasses + ) + + codeDirectoriesOrArchives.forEach { file -> + FilteringAnalyzer(store, coverageBuilder, locationIncludeFilter, logger) + .analyzeAll(file) + } + + return coverageBuilder.getBundle("dummybundle") + } + + companion object { + /** Part of the error message logged when validating the coverage report fails. */ + private const val MOST_LIKELY_CAUSE_MESSAGE = "Most likely you did not configure the agent correctly." + + " Please check that the includes and excludes options are set correctly so the relevant code is included." + + " If in doubt, first include more code and then iteratively narrow the patterns down to just the relevant code." + + " If you have specified the class-dir option, please make sure it points to a directory containing the" + + " class files/jars/wars/ears/etc. for which you are trying to measure code coverage." + + /** Creates an XML report based on the given session and coverage data. */ + @Throws(IOException::class) + private fun createReport( + output: OutputStream, + bundleCoverage: IBundleCoverage, + sessionInfo: SessionInfo?, + store: ExecutionDataStore + ) { + XMLFormatter().createVisitor(output).apply { + visitInfo(listOf(sessionInfo), store.contents) + visitBundle(bundleCoverage, null) + visitEnd() + } + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.kt new file mode 100644 index 000000000..2f7c8304b --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.kt @@ -0,0 +1,64 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2018 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.report.jacoco + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.ILogger +import org.jacoco.core.analysis.CoverageBuilder +import org.jacoco.core.analysis.IClassCoverage +import org.jacoco.core.analysis.ICounter +import org.jacoco.core.internal.analysis.BundleCoverageImpl + +/** + * Modified [CoverageBuilder] can ignore non-identical duplicate classes or classes without coverage. In addition, + * coverage returned via [.getBundle] will only return source file coverage because Teamscale does not + * need class coverage anyway. This reduces XML size by approximately half. + * + * @param logger The logger. + * @param duplicateClassFileBehavior How to behave if duplicate class files are encountered. + * @param ignoreUncoveredClasses Whether to ignore uncovered classes (i.e. leave them out of the report). + */ +internal class TeamscaleCoverageBuilder( + private val logger: ILogger, + private val duplicateClassFileBehavior: EDuplicateClassFileBehavior, + private val ignoreUncoveredClasses: Boolean +) : CoverageBuilder() { + /** Just returns source file coverage, because Teamscale does not need class coverage. */ + override fun getBundle(name: String) = + BundleCoverageImpl(name, emptyList(), sourceFiles) + + /** {@inheritDoc} */ + override fun visitCoverage(coverage: IClassCoverage) { + if (ignoreUncoveredClasses && (coverage.classCounter.status and ICounter.FULLY_COVERED) == 0) { + return + } + + try { + super.visitCoverage(coverage) + } catch (e: IllegalStateException) { + when (duplicateClassFileBehavior) { + EDuplicateClassFileBehavior.IGNORE -> return + EDuplicateClassFileBehavior.WARN -> { + // we do not log the exception here as it does not provide additional valuable information + // and may confuse users into thinking there's a serious + // problem with the agent due to the stack traces in the log + logger.warn( + "Ignoring duplicate, non-identical class file for class ${coverage.name} compiled " + + "from source file ${coverage.sourceFileName}. This happens when a class with the same " + + "fully-qualified name is loaded twice but the two loaded class files are not identical. " + + "A common reason for this is that the same library or shared code is included twice in " + + "your application but in two different versions. The produced coverage for this class " + + "may not be accurate or may even be unusable. To fix this problem, please resolve the " + + "conflict between both class files in your application." + ) + return + } + + else -> throw e + } + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/dump/Dump.kt b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/dump/Dump.kt new file mode 100644 index 000000000..b2f56f848 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/dump/Dump.kt @@ -0,0 +1,15 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2018 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.report.jacoco.dump + +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.SessionInfo + +/** All data received in one dump. */ +data class Dump( + val info: SessionInfo, + val store: ExecutionDataStore +) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/ETestArtifactFormat.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/ETestArtifactFormat.kt new file mode 100644 index 000000000..3e9b15c73 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/ETestArtifactFormat.kt @@ -0,0 +1,23 @@ +package com.teamscale.report.testwise + +/** Enum of test artifacts that can be converted to a full testwise coverage report later on. */ +enum class ETestArtifactFormat( + /** A readable name for the report type. */ + val readableName: String, + /** Prefix to use when writing the report to the file system. */ + val filePrefix: String, + /** File extension of the report. */ + val extension: String +) { + /** A json list of tests ([com.teamscale.client.TestDetails]). */ + TEST_LIST("Test List", "test-list", "json"), + + /** A json list of test executions ([com.teamscale.report.testwise.model.TestExecution]). */ + TEST_EXECUTION("Test Execution", "test-execution", "json"), + + /** Binary jacoco test coverage (.exec file). */ + JACOCO("Jacoco", "", "exec"), + + /** Google closure coverage files with additional uniformPath entries. */ + CLOSURE("Closure Coverage", "closure-coverage", "json") +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/TestwiseCoverageReportWriter.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/TestwiseCoverageReportWriter.kt new file mode 100644 index 000000000..181e0b668 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/TestwiseCoverageReportWriter.kt @@ -0,0 +1,94 @@ +package com.teamscale.report.testwise + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter +import com.teamscale.client.JsonUtils +import com.teamscale.report.testwise.model.TestInfo +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.testwise.model.factory.TestInfoFactory +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.function.Consumer + +/** + * Writes out a [com.teamscale.report.testwise.model.TestwiseCoverageReport] one [TestInfo] after the other + * so that we do not need to keep them all in memory during the conversion. + */ +class TestwiseCoverageReportWriter( + /** Factory for converting [TestCoverageBuilder] objects to [TestInfo]s. */ + private val testInfoFactory: TestInfoFactory, private val outputFile: File, + /** After how many written tests a new file should be started. */ + private val splitAfter: Int +) : Consumer, + AutoCloseable { + /** Writer instance to where the [com.teamscale.report.testwise.model.TestwiseCoverageReport] is written to. */ + private var jsonGenerator: JsonGenerator? = null + + /** Number of tests written to the file. */ + private var testsWritten: Int = 0 + + /** Number of test files that have been written. */ + private var testFileCounter: Int = 0 + + init { + startReport() + } + + override fun accept(testCoverageBuilder: TestCoverageBuilder) { + val testInfo = testInfoFactory.createFor(testCoverageBuilder) + try { + writeTestInfo(testInfo) + } catch (e: IOException) { + // Need to be wrapped in RuntimeException as Consumer does not allow to throw a checked Exception + throw RuntimeException("Writing test info to report failed.", e) + } + } + + @Throws(IOException::class) + override fun close() { + testInfoFactory.createTestInfosWithoutCoverage().forEach { testInfo -> + writeTestInfo(testInfo) + } + endReport() + } + + @Throws(IOException::class) + private fun startReport() { + testFileCounter++ + val outputStream = Files.newOutputStream(getOutputFile(testFileCounter).toPath()) + jsonGenerator = JsonUtils.createFactory().createGenerator(outputStream).apply { + prettyPrinter = DefaultPrettyPrinter() + writeStartObject() + writeFieldName("tests") + writeStartArray() + } + } + + private fun getOutputFile(testFileCounter: Int): File { + var name = outputFile.nameWithoutExtension + + name = "$name-$testFileCounter.json" + return File(outputFile.getParent(), name) + } + + @Throws(IOException::class) + private fun writeTestInfo(testInfo: TestInfo?) { + if (testsWritten >= splitAfter) { + endReport() + testsWritten = 0 + startReport() + } + jsonGenerator?.writeObject(testInfo) + testsWritten++ + } + + @Throws(IOException::class) + private fun endReport() { + jsonGenerator?.let { + it.writeEndArray() + it.writeEndObject() + it.close() + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.kt new file mode 100644 index 000000000..a4609b9d6 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/CachingExecutionDataReader.kt @@ -0,0 +1,117 @@ +package com.teamscale.report.testwise.jacoco + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.jacoco.dump.Dump +import com.teamscale.report.testwise.jacoco.cache.AnalyzerCache +import com.teamscale.report.testwise.jacoco.cache.ProbesCache +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.data.ExecutionDataStore +import java.io.File +import java.util.function.Consumer + +/** + * Helper class for analyzing class files, reading execution data, and converting them to coverage data. + */ +open class CachingExecutionDataReader( + private val logger: ILogger, + private val classesDirectories: Collection, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + private val duplicateClassFileBehavior: EDuplicateClassFileBehavior +) { + private val probeCache: ProbesCache by lazy { + ProbesCache(logger, duplicateClassFileBehavior) + } + + /** + * Analyzes class directories and creates a lookup of probes to methods. + */ + fun analyzeClassDirs() { + if (classesDirectories.isEmpty()) { + logger.warn("No class directories found for caching.") + return + } + val analyzer = AnalyzerCache(probeCache, locationIncludeFilter, logger) + val classCount = classesDirectories + .filter { it.exists() } + .sumOf { analyzeDirectory(it, analyzer) } + + validateAnalysisResult(classCount) + } + + /** + * Analyzes the specified directory, logging errors if any occur. + */ + private fun analyzeDirectory(classDir: File, analyzer: AnalyzerCache) = + runCatching { analyzer.analyzeAll(classDir) } + .onFailure { e -> + logger.error( + "Failed to analyze class files in $classDir! " + + "Maybe the folder contains incompatible class files. Coverage for class files " + + "in this folder will be ignored.", e + ) + } + .getOrDefault(0) + + /** + * Builds a consumer for coverage data. + */ + fun buildCoverageConsumer( + locationIncludeFilter: ClasspathWildcardIncludeFilter, + nextConsumer: Consumer + ) = DumpConsumer(logger, locationIncludeFilter, nextConsumer) + + /** + * Logs errors if no classes were analyzed or if the filter excluded all files. + */ + private fun validateAnalysisResult(classCount: Int) { + val directoryList = classesDirectories.joinToString(",") { it.path } + when { + classCount == 0 -> logger.error("No class files found in directories: $directoryList") + probeCache.isEmpty -> logger.error( + "None of the $classCount class files found in the given directories match the configured include/exclude patterns! $directoryList" + ) + } + } + + /** + * Consumer for processing [Dump] objects and passing them to [TestCoverageBuilder]. + * + * @param logger The logger to use for logging. + * @param locationIncludeFilter The filter to use for including locations. + * @param nextConsumer The consumer to pass the generated [TestCoverageBuilder] to. + */ + inner class DumpConsumer( + private val logger: ILogger, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + private val nextConsumer: Consumer + ) : Consumer { + override fun accept(dump: Dump) { + val testId = dump.info.id.takeIf { it.isNotEmpty() } ?: return logger.debug( + "Session with empty name detected, possibly indicating intermediate coverage." + ) + runCatching { buildCoverage(testId, dump.store, locationIncludeFilter) } + .onSuccess(nextConsumer::accept) + .onFailure { e -> logger.error("Failed to generate coverage for test $testId", e) } + } + + /** + * Builds coverage for a given test and store. + */ + private fun buildCoverage( + testId: String, + executionDataStore: ExecutionDataStore, + locationIncludeFilter: ClasspathWildcardIncludeFilter + ): TestCoverageBuilder { + val testCoverage = TestCoverageBuilder(testId) + executionDataStore.contents.forEach { executionData -> + probeCache.getCoverage(executionData, locationIncludeFilter)?.let { + testCoverage.add(it) + } + } + probeCache.flushLogger() + return testCoverage + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.kt new file mode 100644 index 000000000..3b6b77844 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGenerator.kt @@ -0,0 +1,118 @@ +package com.teamscale.report.testwise.jacoco + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.jacoco.dump.Dump +import com.teamscale.report.testwise.jacoco.CachingExecutionDataReader.* +import com.teamscale.report.testwise.jacoco.cache.CoverageGenerationException +import com.teamscale.report.testwise.model.TestwiseCoverage +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.data.ExecutionData +import org.jacoco.core.data.ExecutionDataReader +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.IExecutionDataVisitor +import org.jacoco.core.data.ISessionInfoVisitor +import org.jacoco.core.data.SessionInfo +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.util.function.Consumer + +/** + * Creates an XML report for an execution data store. The report is grouped by session. + * + * The class files under test must be compiled with debug information otherwise no coverage will be collected. + */ +open class JaCoCoTestwiseReportGenerator( + codeDirectoriesOrArchives: Collection, + private val locationIncludeFilter: ClasspathWildcardIncludeFilter, + duplicateClassFileBehavior: EDuplicateClassFileBehavior, + logger: ILogger +) { + /** The execution data reader and converter. */ + private val executionDataReader = CachingExecutionDataReader( + logger, codeDirectoriesOrArchives, locationIncludeFilter, duplicateClassFileBehavior + ) + + init { + // This has to be unsafe as mockito does not support mocking final classes + updateClassDirCache() + } + + /** Updates the probe cache of the [ExecutionDataReader]. */ + open fun updateClassDirCache() { + executionDataReader.analyzeClassDirs() + } + + /** Converts the given dumps to a report. */ + @Throws(IOException::class, CoverageGenerationException::class) + open fun convert(executionDataFile: File): TestwiseCoverage { + val testwiseCoverage = TestwiseCoverage() + val dumpConsumer = executionDataReader.buildCoverageConsumer(locationIncludeFilter, testwiseCoverage::add) + readAndConsumeDumps(executionDataFile, dumpConsumer) + return testwiseCoverage + } + + /** Converts the given dump to a report. */ + @Throws(CoverageGenerationException::class) + open fun convert(dump: Dump): TestCoverageBuilder? { + val testCoverageBuilders = mutableListOf() + executionDataReader + .buildCoverageConsumer(locationIncludeFilter, testCoverageBuilders::add) + .accept(dump) + return testCoverageBuilders.singleOrNull() + } + + /** Converts the given dumps to a report. */ + @Throws(IOException::class) + open fun convertAndConsume(executionDataFile: File, consumer: Consumer) { + val dumpConsumer = executionDataReader.buildCoverageConsumer(locationIncludeFilter, consumer) + readAndConsumeDumps(executionDataFile, dumpConsumer) + } + + /** Reads the dumps from the given *.exec file. */ + @Throws(IOException::class) + private fun readAndConsumeDumps(executionDataFile: File, dumpConsumer: DumpConsumer) { + BufferedInputStream(FileInputStream(executionDataFile)).use { input -> + ExecutionDataReader(input).apply { + val dumpCallback = DumpCallback(dumpConsumer) + setExecutionDataVisitor(dumpCallback) + setSessionInfoVisitor(dumpCallback) + read() + dumpCallback.processDump() + } + } + } + + /** Collects execution information per session and passes it to the consumer . */ + private class DumpCallback( + private val consumer: DumpConsumer + ) : IExecutionDataVisitor, ISessionInfoVisitor { + /** The dump that is currently being read. */ + private var currentDump: Dump? = null + + /** The store to which coverage is currently written to. */ + private var store: ExecutionDataStore? = null + + override fun visitSessionInfo(info: SessionInfo) { + processDump() + ExecutionDataStore().let { + currentDump = Dump(info, it) + store = it + } + } + + override fun visitClassExecution(data: ExecutionData) { + store?.put(data) + } + + fun processDump() { + currentDump?.let { + consumer.accept(it) + currentDump = null + } + } + } +} \ No newline at end of file diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt new file mode 100644 index 000000000..bb718aaff --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -0,0 +1,70 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.report.jacoco.FilteringAnalyzer +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.internal.analysis.CachingClassAnalyzer +import org.jacoco.core.internal.analysis.ClassCoverageImpl +import org.jacoco.core.internal.analysis.StringPool +import org.jacoco.core.internal.data.CRC64 +import org.jacoco.core.internal.flow.ClassProbesAdapter +import org.jacoco.core.internal.instr.InstrSupport +import java.io.IOException +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.Paths + +/** + * An [AnalyzerCache] instance processes a set of Java class/jar/war/... files and builds a cache for each of the classes. + * + * For every class that gets found, [analyzeClass] is called. A class is identified by its class ID, which + * is a CRC64 checksum of the class file. We process each class with `CachingClassAnalyzer` to fill a cache. + */ +class AnalyzerCache( + private val probesCache: ProbesCache, + locationIncludeFilter: ClasspathWildcardIncludeFilter, + logger: ILogger +) : FilteringAnalyzer(null, null, locationIncludeFilter, logger) { + private val stringPool = StringPool() + + /** + * Analyzes the given class. Instead of the original implementation in [Analyzer.analyzeClass] we + * don't use concrete execution data, but instead build a probe cache to speed up repeated lookups. + */ + override fun analyzeClass(source: ByteArray) { + val classId = CRC64.classId(source) + if (probesCache.containsClassId(classId)) { + return + } + val reader = InstrSupport.classReaderFor(source) + + // Dummy class coverage object that allows us to subclass ClassAnalyzer with CachingClassAnalyzer and reuse its + // IFilterContext implementation + val dummyClassCoverage = ClassCoverageImpl( + reader.className, classId, false + ) + + val classAnalyzer = CachingClassAnalyzer( + probesCache.createClass(classId, reader.className), + dummyClassCoverage, + stringPool + ) + val visitor = ClassProbesAdapter(classAnalyzer, false) + reader.accept(visitor, 0) + } + + /** + * Adds caching for jar files to the analyze jar functionality. + */ + @Throws(IOException::class) + override fun analyzeJar(input: InputStream, location: String): Int { + val jarId = CRC64.classId(Files.readAllBytes(Paths.get(location))) + val probesCountForJarId = probesCache.countForJarId(jarId) + if (probesCountForJarId != 0) { + return probesCountForJarId + } + val count = super.analyzeJar(input, location) + probesCache.addJarId(jarId, count) + return count + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.kt new file mode 100644 index 000000000..034e78548 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassCoverageLookup.kt @@ -0,0 +1,75 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.client.StringUtils +import com.teamscale.report.testwise.model.builder.FileCoverageBuilder +import com.teamscale.report.util.CompactLines +import com.teamscale.report.util.ILogger +import org.jacoco.core.data.ExecutionData + +/** + * Holds information about a class' probes and to which line ranges they refer. + * + * Create an instance of this class for every analyzed java class. + * Set the file name of the java source file from which the class has been created. + * Then call [addProbe] for all probes and lines that belong to that probe. + * Afterward call [getFileCoverage] to transform probes ([ExecutionData]) for this class into covered lines + * ([FileCoverageBuilder]). + * + * @param className Classname as stored in the bytecode e.g., com/company/Example + */ +class ClassCoverageLookup internal constructor( + private val className: String +) { + var sourceFileName: String? = null + private val probes = mutableMapOf() + + /** Adds the probe with the given id to the method. */ + fun addProbe(probeId: Int, lines: CompactLines) { + probes[probeId] = lines + } + + /** + * Generates [FileCoverageBuilder] from an [ExecutionData]. [ExecutionData] holds coverage of + * exactly one class (whereby inner classes are a separate class). This method returns a [FileCoverageBuilder] + * object which is later merged with the [FileCoverageBuilder] of other classes that reside in the same file. + */ + @Throws(CoverageGenerationException::class) + fun getFileCoverage(executionData: ExecutionData, logger: ILogger): FileCoverageBuilder? { + val executedProbes = executionData.probes + + when { + probes.size > executedProbes.size -> throw CoverageGenerationException( + "Probe lookup does not match with actual probe size for $sourceFileName $className (${probes.size} vs ${executedProbes.size})! This is a bug in the profiler tooling. Please report it back to CQSE." + ) + + sourceFileName == null -> { + logger.warn("No source file name found for class $className! This class was probably not compiled with debug information enabled!") + return null + } + } + + val packageName = if (className.contains("/")) StringUtils.removeLastPart(className, '/') else "" + return FileCoverageBuilder(packageName, sourceFileName!!).apply { + fillFileCoverage(this, executedProbes, logger) + } + } + + private fun fillFileCoverage(fileCoverage: FileCoverageBuilder, executedProbes: BooleanArray, logger: ILogger) { + probes.forEach { (probeId, coveredLines) -> + if (executedProbes.getOrNull(probeId) == true) { + when { + coveredLines.isEmpty() -> logger.debug( + "$sourceFileName $className contains a method with no line information. Does the class contain debug information?" + ) + + else -> fileCoverage.addLines(coveredLines) + } + } else { + logger.info( + "$sourceFileName $className contains a covered probe $probeId that could not be matched to any method. " + + "This could be a bug in the profiler tooling. Please report it back to CQSE." + ) + } + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.kt new file mode 100644 index 000000000..5e9edb647 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ClassNotFoundLogger.kt @@ -0,0 +1,40 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.report.util.ILogger + +/** + * Coordinates logging of missing class files to ensure the warnings are only emitted once and not for every individual + * test. + */ +internal class ClassNotFoundLogger( + private val logger: ILogger +) { + /** Missing classes that will be logged when [.flush] is called. */ + private val classesToBeLogged = hashSetOf() + + /** Classes that have already been reported as missing. */ + private val alreadyLoggedClasses = hashSetOf() + + /** Saves the given class to be logged later on. Ensures that the class is only logged once. */ /* package */ + fun log(fullyQualifiedClassName: String) { + if (alreadyLoggedClasses.contains(fullyQualifiedClassName)) return + classesToBeLogged.add(fullyQualifiedClassName) + } + + /** Writes a summary of the missing class files to the logger. */ /* package */ + fun flush() { + if (classesToBeLogged.isEmpty()) return + + logger.warn( + "Found coverage for " + classesToBeLogged + .size + " classes that were not provided. Either you did not provide " + + "all relevant class files or you did not adjust the include/exclude filters on the agent to exclude " + + "coverage from irrelevant code. The classes are:" + ) + classesToBeLogged.forEach { fullyQualifiedClassName -> + logger.warn(" - $fullyQualifiedClassName") + } + alreadyLoggedClasses.addAll(classesToBeLogged) + classesToBeLogged.clear() + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.kt new file mode 100644 index 000000000..69cf63a56 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/CoverageGenerationException.kt @@ -0,0 +1,6 @@ +package com.teamscale.report.testwise.jacoco.cache + +/** + * Exception thrown during coverage generation. + */ +class CoverageGenerationException(message: String) : Exception(message) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt new file mode 100644 index 000000000..a6dcca6ef --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/ProbesCache.kt @@ -0,0 +1,97 @@ +package com.teamscale.report.testwise.jacoco.cache + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.testwise.model.builder.FileCoverageBuilder +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.ILogger +import org.jacoco.core.data.ExecutionData +import org.jacoco.report.JavaNames + +/** + * Holds [ClassCoverageLookup]s for all analyzed classes. + * + * @param logger The logger to use for logging. + * @param duplicateClassFileBehavior Whether to ignore non-identical duplicates of class files. + */ +class ProbesCache( + private val logger: ILogger, + private val duplicateClassFileBehavior: EDuplicateClassFileBehavior +) { + /** A mapping from class ID (CRC64 of the class file) to [ClassCoverageLookup]. */ + private val classCoverageLookups = hashMapOf() + + /** Holds all fully qualified class names that are already contained in the cache. */ + private val containedClasses = mutableSetOf() + private val containedJars = mutableMapOf() + private val classNotFoundLogger = ClassNotFoundLogger(logger) + + /** Adds a new class entry to the cache and returns its [ClassCoverageLookup]. */ + fun createClass(classId: Long, className: String): ClassCoverageLookup { + if (containedClasses.contains(className)) { + if (duplicateClassFileBehavior != EDuplicateClassFileBehavior.IGNORE) { + logger.warn( + "Non-identical class file for class $className. This happens when a class with the " + + "same fully-qualified name is loaded twice but the two loaded class files are not identical. " + + "A common reason for this is that the same library or shared code is included twice in your " + + "application but in two different versions. The produced coverage for this class may not be " + + "accurate or may even be unusable. To fix this problem, please resolve the conflict between " + + "both class files in your application." + ) + } + check(duplicateClassFileBehavior != EDuplicateClassFileBehavior.FAIL) { "Found non-identical class file for class $className. See logs for more details." } + } + containedClasses.add(className) + val classCoverageLookup = ClassCoverageLookup(className) + classCoverageLookups[classId] = classCoverageLookup + return classCoverageLookup + } + + /** Returns whether a class with the given class ID has already been analyzed. */ + fun containsClassId(classId: Long) = + classCoverageLookups.containsKey(classId) + + /** + * Returns the number of found class files in a cached jar file. Otherwise 0. + */ + fun countForJarId(jarId: Long) = + containedJars.getOrDefault(jarId, 0) + + /** + * Adds a jar id along with the count of class files found in the jar. + */ + fun addJarId(jarId: Long, count: Int) { + containedJars[jarId] = count + } + + /** + * Converts the given [ExecutionData] to [FileCoverageBuilder] using the cached lookups or null if the + * class file of this class has not been included in the analysis or was not covered. + */ + @Throws(CoverageGenerationException::class) + fun getCoverage( + executionData: ExecutionData, + locationIncludeFilter: ClasspathWildcardIncludeFilter + ): FileCoverageBuilder? { + val classId = executionData.id + if (!containsClassId(classId)) { + val fullyQualifiedClassName = JavaNames().getQualifiedClassName(executionData.name) + if (locationIncludeFilter.isIncluded("$fullyQualifiedClassName.class")) { + classNotFoundLogger.log(fullyQualifiedClassName) + } + return null + } + if (!executionData.hasHits()) { + return null + } + + return classCoverageLookups[classId]?.getFileCoverage(executionData, logger) + } + + /** Returns true if the cache does not contain coverage for any class. */ + val isEmpty: Boolean + get() = classCoverageLookups.isEmpty() + + fun flushLogger() { + classNotFoundLogger.flush() + } +} 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 new file mode 100644 index 000000000..566aa148a --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ERevisionType.kt @@ -0,0 +1,10 @@ +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/java/com/teamscale/report/testwise/model/ETestExecutionResult.java b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ETestExecutionResult.kt similarity index 82% rename from report-generator/src/main/java/com/teamscale/report/testwise/model/ETestExecutionResult.java rename to report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ETestExecutionResult.kt index 034082081..1c9497ccf 100644 --- a/report-generator/src/main/java/com/teamscale/report/testwise/model/ETestExecutionResult.java +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/ETestExecutionResult.kt @@ -15,23 +15,22 @@ | limitations under the License. | | | +-------------------------------------------------------------------------*/ -package com.teamscale.report.testwise.model; +package com.teamscale.report.testwise.model -/** The result of a test execution. */ -public enum ETestExecutionResult { - - /** Test execution was successful. */ +/** The result of a test execution. */ +enum class ETestExecutionResult { + /** Test execution was successful. */ PASSED, - /** The test is currently marked as "do not execute" (e.g. JUnit @Ignore). */ + /** The test is currently marked as "do not execute" (e.g. JUnit @Ignore). */ IGNORED, - /** Caused by a failing assumption. */ + /** Caused by a failing assumption. */ SKIPPED, - /** Caused by a failing assertion. */ + /** Caused by a failing assertion. */ FAILURE, - /** Caused by an error during test execution (e.g. exception thrown in the test runner code, not the test itself). */ + /** Caused by an error during test execution (e.g. exception thrown in the test runner code, not the test itself). */ ERROR } 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 new file mode 100644 index 000000000..68d8ddd1d --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/FileCoverage.kt @@ -0,0 +1,12 @@ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** Holds coverage of a single file. */ +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. */ + @JvmField @param:JsonProperty("coveredLines") val coveredLines: String +) diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/LineRange.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/LineRange.kt new file mode 100644 index 000000000..e8ebb3b82 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/LineRange.kt @@ -0,0 +1,18 @@ +package com.teamscale.report.testwise.model + +/** Holds a line range with start and end (both inclusive and 1-based). */ +data class LineRange( + private val start: Int, + var end: Int +) { + /** + * Returns the line range as used in the XML report. + * A range is returned as e.g. 2-5 or simply 3 if the start and end are equal. + */ + override fun toString() = + if (start == end) { + start.toString() + } else { + "$start-$end" + } +} 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 new file mode 100644 index 000000000..0f87fdeb7 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/PathCoverage.kt @@ -0,0 +1,12 @@ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** Container for [FileCoverage]s of the same path. */ +class PathCoverage @JsonCreator constructor( + /** File system path. */ + @param:JsonProperty("path") val path: String?, + /** Files with coverage. */ + @JvmField @param:JsonProperty("files") val files: List +) 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 new file mode 100644 index 000000000..87a2a8ae8 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/RevisionInfo.kt @@ -0,0 +1,35 @@ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +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 + + /** The value. Either a commit descriptor or a source control revision, depending on [type]. */ + val value: String? + + @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() + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestExecution.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestExecution.kt new file mode 100644 index 000000000..788165a64 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestExecution.kt @@ -0,0 +1,50 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2005-2018 The ConQAT Project | +| | +| Licensed under the Apache License, Version 2.0 (the "License"); | +| you may not use this file except in compliance with the License. | +| You may obtain a copy of the License at | +| | +| http://www.apache.org/licenses/LICENSE-2.0 | +| | +| Unless required by applicable law or agreed to in writing, software | +| distributed under the License is distributed on an "AS IS" BASIS, | +| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | +| See the License for the specific language governing permissions and | +| limitations under the License. | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.annotation.JsonProperty +import java.io.Serializable + +/** + * Representation of a single test (method) execution. + * + * @param uniformPath The uniform path of the test (method) that was executed. + * This is an absolute reference that identifies the test uniquely within the Teamscale project. + * @param durationMillis Duration of the execution in milliseconds. + * @param result The result of the test execution. + * @param message Optional message given for test failures (normally contains a stack trace). May be `null`. + */ +data class TestExecution @JvmOverloads constructor( + @JvmField + var uniformPath: String? = null, + @Deprecated("Use durationSeconds instead.") + var durationMillis: Long = 0L, + @JvmField + val result: ETestExecutionResult? = null, + val message: String? = null, +) : Serializable { + + /** Duration of the execution in seconds. */ + @JsonProperty("duration") + @JsonAlias("durationSeconds") + private val duration: Double? = null + + val durationSeconds: Double + get() = duration ?: (durationMillis / 1000.0) +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestInfo.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestInfo.kt new file mode 100644 index 000000000..5f0efd1c4 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestInfo.kt @@ -0,0 +1,33 @@ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** Generic container of all information about a specific test as written to the report. */ +class TestInfo @JsonCreator constructor( + /** Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. */ + @JvmField @param:JsonProperty("uniformPath") val uniformPath: String, + /** + * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a Base + * class will have the sourcePath pointing to the Base class which contains the actual implementation whereas + * uniformPath will contain the class name of the most specific subclass, from where it was actually executed. + */ + @param:JsonProperty("sourcePath") val sourcePath: String?, + /** + * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the + * specification or similar. + */ + @param:JsonProperty("content") val content: String?, + /** Duration of the execution in seconds. */ + @param:JsonProperty("duration") val duration: Double?, + /** The actual execution result state. */ + @JvmField @param:JsonProperty("result") val result: ETestExecutionResult?, + /** + * Optional message given for test failures (normally contains a stack trace). May be `null`. + */ + @param:JsonProperty("message") val message: String? +) { + /** All paths that the test did cover. */ + @JvmField + val paths = mutableListOf() +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverage.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverage.kt new file mode 100644 index 000000000..1f7871522 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverage.kt @@ -0,0 +1,32 @@ +package com.teamscale.report.testwise.model + +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder + +/** Container for coverage produced by multiple tests. */ +class TestwiseCoverage { + /** A mapping from test ID to [TestCoverageBuilder]. */ + val tests = mutableMapOf() + + /** + * Adds the [TestCoverageBuilder] to the map. + * If there is already a test with the same ID the coverage is merged. + */ + fun add(coverage: TestCoverageBuilder?) { + if (coverage == null || coverage.isEmpty) return + if (tests.containsKey(coverage.uniformPath)) { + tests[coverage.uniformPath]?.addAll(coverage.files) + } else { + tests[coverage.uniformPath] = coverage + } + } + + /** + * Merges the given [TestwiseCoverage] with this one. + */ + fun add(testwiseCoverage: TestwiseCoverage?) { + if (testwiseCoverage == null) return + testwiseCoverage.tests.values.forEach { value -> + add(value) + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverageReport.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverageReport.kt new file mode 100644 index 000000000..b2053f810 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/TestwiseCoverageReport.kt @@ -0,0 +1,17 @@ +package com.teamscale.report.testwise.model + +import com.fasterxml.jackson.annotation.JsonProperty + +/** Container for coverage produced by multiple tests. */ +data class TestwiseCoverageReport( + /** + * If set to `true` the set of tests contained in the report don't represent the full set of tests within a + * partition. These tests are added or updated in Teamscale, but no tests or executable units that are missing in + * the report will be deleted. + */ + @JvmField @param:JsonProperty("partial") val partial: Boolean +) { + /** The tests contained in the report. */ + @JvmField + val tests = mutableListOf() +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.kt new file mode 100644 index 000000000..8f3fd7b38 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilder.kt @@ -0,0 +1,66 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.report.testwise.model.FileCoverage +import com.teamscale.report.testwise.model.LineRange +import com.teamscale.report.util.CompactLines +import com.teamscale.report.util.CompactLines.Companion.compactLinesOf + +/** Holds coverage of a single file. */ +class FileCoverageBuilder( + /** The file system path of the file not including the file itself. */ + val path: String, + /** The name of the file. */ + val fileName: String +) { + /** + * A set of line numbers that have been covered. Ensures order and uniqueness. + */ + private val coveredLines = compactLinesOf() + + /** Adds a line as covered. */ + fun addLine(line: Int) = coveredLines.add(line) + + /** Adds a line range as covered. */ + fun addLineRange(start: Int, end: Int) = (start..end).forEach { coveredLines.add(it) } + + /** Adds a set of lines as covered. */ + fun addLines(lines: CompactLines) = coveredLines merge lines + + /** Merges the coverage of another [FileCoverageBuilder] into the current list. */ + fun merge(other: FileCoverageBuilder) { + require(other.fileName == fileName && other.path == path) { + "Cannot merge coverage of two different files! This is a bug!" + } + coveredLines merge other.coveredLines + } + + /** + * Returns a compact string representation of the covered lines. Continuous line ranges are merged to ranges and + * sorted. Individual ranges are separated by commas. E.g. 1-5,7,9-11. + */ + fun computeCompactifiedRangesAsString(): String = + compactifyToRanges(coveredLines).joinToString(",") + + /** Returns true if there is no coverage for the file yet. */ + val isEmpty: Boolean get() = coveredLines.isEmpty() + + /** Builds the [FileCoverage] object, which is serialized into the report. */ + fun build(): FileCoverage = FileCoverage(fileName, computeCompactifiedRangesAsString()) + + companion object { + /** + * Merges all neighboring line numbers to ranges. E.g. a list of [[1-5],[3-7],[8-10],[12-14]] becomes + * [[1-10],[12-14]] + */ + @JvmStatic + fun compactifyToRanges(lines: CompactLines): List = + lines.fold(mutableListOf()) { ranges, line -> + if (ranges.isNotEmpty() && ranges.last().end >= line - 1) { + ranges.last().end = line + } else { + ranges.add(LineRange(line, line)) + } + ranges + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.kt new file mode 100644 index 000000000..e73dc2d42 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/PathCoverageBuilder.kt @@ -0,0 +1,37 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.report.testwise.model.PathCoverage + +/** Container for [FileCoverageBuilder]s of the same path. + * + * @param path File system path. + */ +class PathCoverageBuilder( + val path: String +) { + /** Mapping from file names to [FileCoverageBuilder]. */ + private val fileCoverageList = mutableMapOf() + + /** + * Adds the given [FileCoverageBuilder] to the container. + * If coverage for the same file already exists, it gets merged. + */ + fun add(fileCoverage: FileCoverageBuilder) { + fileCoverageList.merge(fileCoverage.fileName, fileCoverage) { existing, new -> + existing.apply { merge(new) } + } + } + + /** Returns a collection of [FileCoverageBuilder]s associated with this path. */ + val files: Collection + get() = fileCoverageList.values + + /** Builds a [PathCoverage] object. */ + fun build() = + PathCoverage( + path, + fileCoverageList.values + .sortedBy { it.fileName } + .map { it.build() } + ) +} \ No newline at end of file diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.kt new file mode 100644 index 000000000..a46158290 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestCoverageBuilder.kt @@ -0,0 +1,41 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.report.testwise.model.PathCoverage + +/** + * Generic holder of test coverage of a single test based on line-ranges. + * @param uniformPath The uniformPath of the test (see TEST_IMPACT_ANALYSIS_DOC.md for more information). + */ +class TestCoverageBuilder(val uniformPath: String) { + /** Mapping from path names to all files on this path. */ + private val pathCoverageList = mutableMapOf() + + /** Collection of [PathCoverageBuilder]s associated with the test. */ + val paths: List + get() = pathCoverageList.values + .sortedBy { it.path } + .map { it.build() } + + /** Adds the [FileCoverageBuilder] to into the map, but filters out file coverage that is null or empty. */ + fun add(fileCoverage: FileCoverageBuilder) { + fileCoverage.takeIf { !it.isEmpty }?.let { coverage -> + pathCoverageList.computeIfAbsent(coverage.path) { PathCoverageBuilder(it) } + .add(coverage) + } + } + + /** Adds the [FileCoverageBuilder]s into the map, but filters out empty ones. */ + fun addAll(fileCoverageList: List) { + fileCoverageList.forEach { add(it) } + } + + /** Returns all [FileCoverageBuilder]s stored for the test. */ + val files: List + get() = pathCoverageList.values + .flatMap { it.files } + .toList() + + /** Returns true if there is no coverage for the test yet. */ + val isEmpty: Boolean + get() = pathCoverageList.isEmpty() +} \ No newline at end of file diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestInfoBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestInfoBuilder.kt new file mode 100644 index 000000000..dc8c1c4d5 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestInfoBuilder.kt @@ -0,0 +1,67 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.client.TestDetails +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo + +/** Generic container of all information about a specific test including details, execution info and coverage. + * @param uniformPath Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. + */ +class TestInfoBuilder(val uniformPath: String) { + /** + * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a base + * class will have the sourcePath pointing to the base class which contains the actual implementation whereas + * uniformPath will contain the class name of the most specific subclass, from where it was actually executed. + */ + private var sourcePath: String? = null + + /** + * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the + * specification or similar. + */ + private var content: String? = null + + /** Duration of the execution in milliseconds. */ + private var durationSeconds: Double? = null + + /** The actual execution result state. */ + private var result: ETestExecutionResult? = null + + /** + * Optional message given for test failures (normally contains a stack trace). May be `null`. + */ + private var message: String? = null + + /** Coverage generated by this test. */ + private var coverage: TestCoverageBuilder? = null + + /** Returns true if there is no coverage for the test yet. */ + val isEmpty: Boolean + get() = coverage?.isEmpty == true + + /** Sets the test details fields. */ + fun setDetails(details: TestDetails) { + sourcePath = details.sourcePath + content = details.content + } + + /** Sets the test execution fields. */ + fun setExecution(execution: TestExecution) { + durationSeconds = execution.durationSeconds + result = execution.result + message = execution.message + } + + fun setCoverage(coverage: TestCoverageBuilder) { + this.coverage = coverage + } + + /** Builds a [TestInfo] object of the data in this container. */ + fun build() = + TestInfo( + uniformPath, sourcePath, content, durationSeconds, result, message + ).apply { + paths.addAll(coverage?.paths ?: emptyList()) + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.kt new file mode 100644 index 000000000..072433ad4 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/builder/TestwiseCoverageReportBuilder.kt @@ -0,0 +1,66 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.client.TestDetails +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestwiseCoverageReport + +/** Container for coverage produced by multiple tests. */ +class TestwiseCoverageReportBuilder { + /** A mapping from test ID to [TestCoverageBuilder]. */ + private val tests = mutableMapOf() + + private fun build(partial: Boolean): TestwiseCoverageReport { + val report = TestwiseCoverageReport(partial) + tests.values + .sortedBy { it.uniformPath } + .map { it.build() } + .forEach { report.tests.add(it) } + return report + } + + companion object { + /** + * Adds the [TestCoverageBuilder] to the map. If there is already a test with the same ID the coverage is + * merged. + */ + @JvmStatic + fun createFrom( + testDetailsList: Collection, + testCoverage: Collection, + testExecutions: Collection, + partial: Boolean + ): TestwiseCoverageReport { + val report = TestwiseCoverageReportBuilder() + testDetailsList.forEach { testDetails -> + TestInfoBuilder(testDetails.uniformPath).also { + it.setDetails(testDetails) + report.tests[testDetails.uniformPath] = it + } + } + testCoverage.forEach { coverage -> + resolveUniformPath(report, coverage.uniformPath)?.setCoverage(coverage) + } + testExecutions.forEach { testExecution -> + val path = testExecution.uniformPath ?: return@forEach + resolveUniformPath(report, path)?.setExecution(testExecution) + } + return report.build(partial) + } + + private fun resolveUniformPath(report: TestwiseCoverageReportBuilder, uniformPath: String) = + if (report.tests.containsKey(uniformPath)) { + report.tests[uniformPath] + } else { + val shortenedUniformPath = stripParameterizedTestArguments(uniformPath) + report.tests[shortenedUniformPath] + } ?: run { + System.err.println("No container found for test '$uniformPath'!"); null + } + + /** + * Removes parameterized test arguments from the given uniform path. + */ + fun stripParameterizedTestArguments(uniformPath: String) = + uniformPath.replaceFirst("(.*\\))\\[.*]".toRegex(), "$1") + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/factory/TestInfoFactory.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/factory/TestInfoFactory.kt new file mode 100644 index 000000000..f26c3dc51 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/model/factory/TestInfoFactory.kt @@ -0,0 +1,83 @@ +package com.teamscale.report.testwise.model.factory + +import com.teamscale.client.TestDetails +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import com.teamscale.report.testwise.model.builder.TestCoverageBuilder +import com.teamscale.report.testwise.model.builder.TestInfoBuilder +import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder + +/** + * Factory class for converting [TestCoverageBuilder] to [TestInfo]s while augmenting them with information + * from test details and test executions. + */ +class TestInfoFactory(testDetails: List, testExecutions: List) { + /** Maps uniform paths to test details. */ + private val testDetailsMap = mutableMapOf() + + /** Maps uniform paths to test executions. */ + private val testExecutionsMap = mutableMapOf() + + /** Holds all uniform paths for tests that have been written to the outputFile. */ + private val processedTestUniformPaths = mutableSetOf() + + init { + testDetails.forEach { testDetail -> + testDetailsMap[testDetail.uniformPath] = testDetail + } + testExecutions.forEach { testExecution -> + testExecution.uniformPath?.let { + testExecutionsMap[it] = testExecution + } + } + } + + /** + * Converts the given [TestCoverageBuilder] to a [TestInfo] using the internally stored test details and + * test executions. + */ + fun createFor(testCoverageBuilder: TestCoverageBuilder): TestInfo { + val resolvedUniformPath = testCoverageBuilder.uniformPath.resolveUniformPath() + processedTestUniformPaths.add(resolvedUniformPath) + + return TestInfoBuilder(resolvedUniformPath).apply { + setCoverage(testCoverageBuilder) + testDetailsMap[resolvedUniformPath]?.let { testDetails -> + setDetails(testDetails) + } ?: System.err.println("No test details found for $resolvedUniformPath") + testExecutionsMap[resolvedUniformPath]?.let { execution -> + setExecution(execution) + } ?: System.err.println("No test execution found for $resolvedUniformPath") + }.build() + } + + /** Returns [TestInfo]s for all tests that have not been used yet in [.createFor]. */ + fun createTestInfosWithoutCoverage(): List { + val results = testDetailsMap.values.mapNotNull { testDetails -> + if (processedTestUniformPaths.contains(testDetails.uniformPath)) return@mapNotNull null + + processedTestUniformPaths.add(testDetails.uniformPath) + TestInfoBuilder(testDetails.uniformPath).apply { + setDetails(testDetails) + testExecutionsMap[testDetails.uniformPath]?.let { setExecution(it) } + }.build() + } + testExecutionsMap.values.forEach { testExecution -> + if (processedTestUniformPaths.contains(testExecution.uniformPath)) return@forEach + System.err.println( + "Test " + testExecution.uniformPath + " was executed but no coverage was found. " + + "Please make sure that you did provide all relevant exec files and that the test IDs passed to " + + "the agent match the ones from the provided test execution list." + ) + testExecution.uniformPath?.let { processedTestUniformPaths.add(it) } + } + return results + } + + /** + * Strips parameterized test arguments when the full path given in the coverage file cannot be found in the test + * details. + */ + private fun String.resolveUniformPath() = + testDetailsMap[this]?.uniformPath ?: TestwiseCoverageReportBuilder.stripParameterizedTestArguments(this) +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/AntPatternIncludeFilter.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/AntPatternIncludeFilter.kt new file mode 100644 index 000000000..0d89e67e8 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/AntPatternIncludeFilter.kt @@ -0,0 +1,47 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2018 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.report.util + +import com.teamscale.client.AntPatternUtils +import com.teamscale.client.FileSystemUtils +import java.util.function.Predicate +import java.util.regex.Pattern + +/** + * Applies ANT include and exclude patterns to paths. + */ +class AntPatternIncludeFilter( + locationIncludeFilters: List, + locationExcludeFilters: List +) : Predicate { + /** The include filters. Empty means include everything. */ + private val locationIncludeFilters: List = + locationIncludeFilters.map { AntPatternUtils.convertPattern(it, false) } + + /** The exclude filters. Empty means exclude nothing. */ + private val locationExcludeFilters: List = + locationExcludeFilters.map { AntPatternUtils.convertPattern(it, false) } + + /** {@inheritDoc} */ + override fun test(path: String) = !isFiltered(FileSystemUtils.normalizeSeparators(path)) + + /** + * Returns `true` if the given class file location (normalized to forward slashes as path separators) + * should not be analyzed. + * + * + * Exclude filters overrule include filters. + */ + private fun isFiltered(location: String): Boolean { + // first check includes + val noneIncludes = locationIncludeFilters.none { it.matcher(location).matches() } + if (locationIncludeFilters.isNotEmpty() && noneIncludes) { + return true + } + // only if they match, check excludes + return locationExcludeFilters.any { it.matcher(location).matches() } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/BashFileSkippingInputStream.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/BashFileSkippingInputStream.kt new file mode 100644 index 000000000..ba9834456 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/BashFileSkippingInputStream.kt @@ -0,0 +1,67 @@ +package com.teamscale.report.util + +import java.io.BufferedInputStream +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream + +/** + * This InputStream skips any initial bash script prepended to an executable spring-boot jar file, + * positioning the stream at the start of the ZIP file header. This allows direct execution of + * the jar without requiring "java -jar my.jar". + */ +class BashFileSkippingInputStream(input: InputStream) : FilterInputStream(BufferedInputStream(input)) { + + companion object { + private val ZIP_HEADER = byteArrayOf(0x50, 0x4B, 0x03, 0x04) + private const val BUFFER_SIZE = 8192 + } + + init { + skipToZipHeader() + } + + /** + * Reads the stream until the ZIP file header (0x50 4B 03 04) is found, or EOF is reached. + * After calling this method, the read pointer is positioned at the first byte of the ZIP header. + */ + @Throws(IOException::class) + private fun skipToZipHeader() { + val buffer = ByteArray(BUFFER_SIZE) + `in`.mark(BUFFER_SIZE) + + var bytesRead = `in`.read(buffer, 0, BUFFER_SIZE) + while (bytesRead != -1) { + val headerIndex = findZipHeader(buffer, bytesRead) + if (headerIndex != -1) { + // Reset and skip to the start of the ZIP header + `in`.reset() + `in`.skip(headerIndex.toLong()) + return + } + + // Adjust position and re-mark to check the buffer boundary for header + `in`.reset() + `in`.skip((BUFFER_SIZE - ZIP_HEADER.size + 1).toLong()) + `in`.mark(BUFFER_SIZE) + bytesRead = `in`.read(buffer, 0, BUFFER_SIZE) + } + + throw IOException("ZIP header not found in the input stream.") + } + + /** + * Searches the buffer for the ZIP header signature. + * @param buffer The buffer to search. + * @param length The number of valid bytes in the buffer. + * @return The index where the ZIP header starts, or -1 if not found. + */ + private fun findZipHeader(buffer: ByteArray, length: Int) = + (0..length - ZIP_HEADER.size) + .firstOrNull { + buffer[it] == ZIP_HEADER[0] + && buffer[it + 1] == ZIP_HEADER[1] + && buffer[it + 2] == ZIP_HEADER[2] + && buffer[it + 3] == ZIP_HEADER[3] + } ?: -1 +} 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 new file mode 100644 index 000000000..7de836219 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilter.kt @@ -0,0 +1,80 @@ +package com.teamscale.report.util + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.StringUtils +import org.jacoco.core.runtime.WildcardMatcher +import org.jacoco.report.JavaNames +import java.util.* + + +/*** + * Tests given class file paths against call name patterns. + * E.g. "/some/file/path/test.jar@my/package/Test.class" matches "my/package/ *" or "my/package/Test" + */ +open class ClasspathWildcardIncludeFilter( + locationIncludeFilters: String?, + locationExcludeFilters: String? +) { + /** + * Include patterns to apply during JaCoCo's traversal of class files. If null then everything is included. + */ + private var locationIncludeFilters: WildcardMatcher? = null + + /** + * Exclude patterns to apply during JaCoCo's traversal of class files. If null then nothing is excluded. + */ + private var locationExcludeFilters: WildcardMatcher? = null + + /** + * Constructor. + * + * @param locationIncludeFilters Colon separated list of wildcard include patterns for fully qualified class names + * or null for no includes. See [WildcardMatcher] for the pattern syntax. + * @param locationExcludeFilters Colon separated list of wildcard exclude patterns for fully qualified class names + * or null for no excludes.See [WildcardMatcher] for the pattern syntax. + */ + init { + if (!locationIncludeFilters.isNullOrEmpty()) { + this.locationIncludeFilters = WildcardMatcher(locationIncludeFilters) + } + if (!locationExcludeFilters.isNullOrEmpty()) { + this.locationExcludeFilters = WildcardMatcher(locationExcludeFilters) + } + } + + /** + * Tests if the given file path (e.g. "/some/file/path/test.jar@my/package/Test.class" or "org/mypackage/MyClass" + */ + 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 { + /** + * 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". + */ + @JvmStatic + fun getClassName(path: String): String { + val parts = FileSystemUtils.normalizeSeparators(path) + .split("@".toRegex()).dropLastWhile { it.isEmpty() } + .toTypedArray() + if (parts.isEmpty()) { + return "" + } + + var pathInsideJar = parts[parts.size - 1] + if (path.lowercase(Locale.getDefault()).endsWith(".class")) { + pathInsideJar = StringUtils.removeLastPart(pathInsideJar, '.') + } + return JavaNames().getQualifiedClassName(pathInsideJar) + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/CommandLineLogger.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/CommandLineLogger.kt new file mode 100644 index 000000000..fe7788234 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/CommandLineLogger.kt @@ -0,0 +1,30 @@ +package com.teamscale.report.util + +/** Logger that prints all output to the console. */ +class CommandLineLogger : ILogger { + override fun debug(message: String) { + println(message) + } + + override fun info(message: String) { + println(message) + } + + override fun warn(message: String) { + System.err.println(message) + } + + override fun warn(message: String, throwable: Throwable?) { + System.err.println(message) + throwable?.printStackTrace() + } + + override fun error(throwable: Throwable) { + throwable.printStackTrace() + } + + override fun error(message: String, throwable: Throwable?) { + System.err.println(message) + throwable?.printStackTrace() + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/CompactLines.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/CompactLines.kt new file mode 100644 index 000000000..9a8bfcf75 --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/CompactLines.kt @@ -0,0 +1,212 @@ +/* + * Copyright (c) CQSE GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.teamscale.report.util + +import java.io.IOException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable +import java.util.BitSet +import java.util.NoSuchElementException + +/** + * A compact, serializable representation of line numbers using a BitSet. This class is designed to + * efficiently store and manipulate sets of line numbers, which is particularly useful for tracking + * coverage information, regions of a text, or any scenario where line-based data needs to be + * compactly managed. + * + * Instances of this class can be created empty, from a collection of integers, or from a string + * representation of line number ranges. It supports basic set operations such as addition, removal, + * intersection, and union, as well as specialized operations like checking if any line number + * within a range or specific region is present. + * + * This class also implements [Iterable], allowing for easy iteration over all stored line + * numbers. + * + * @see BitSet + */ +data class CompactLines( + private var bitSet: BitSet = BitSet() +) : Serializable, Iterable { + + companion object { + private const val serialVersionUID = 1L + + fun compactLinesOf(vararg lines: Int) = CompactLines(*lines) + fun compactLinesOf(lines: Iterable) = CompactLines(lines) + fun compactLinesOf() = CompactLines() + } + + constructor(lines: Iterable) : this() { + lines.forEach { line -> + bitSet.set(line) + } + } + + constructor(vararg lines: Int) : this() { + lines.forEach { line -> + bitSet.set(line) + } + } + + /** Returns the number of line numbers in this set. */ + fun size() = bitSet.cardinality() + + /** + * Checks if this set of line numbers is empty. + * + * @return `true` if there are no line numbers in this set, `false` otherwise. + */ + fun isEmpty() = bitSet.isEmpty + + /** + * Adds all line numbers from another [CompactLines] instance to this one. + */ + infix fun merge(lines: CompactLines) { + bitSet.or(lines.bitSet) + } + + /** + * Checks if a specific line number is present in this set. + * + * @param line The line number (1-based) + * @return `true` if the line number is present, `false` otherwise. + */ + fun contains(line: Int) = bitSet.get(line) + + /** + * Checks if any line number within a specified range is present in this set. + * + * @param start the start of the range (inclusive, 1-based). + * @param end the end of the range (inclusive, 1-based). + * @return `true` if any line number within the range is present, `false` otherwise. + */ + fun containsAny(start: Int, end: Int): Boolean { + val nextSetBit = bitSet.nextSetBit(start) + return nextSetBit != -1 && nextSetBit <= end + } + + /** + * Checks if this set contains all the line numbers specified in an iterable collection. + * + * @return `true` if every line number in the collection is contained in this set, + * `false` otherwise. + */ + fun containsAll(lines: Iterable) = + lines.all { line -> bitSet.get(line) } + + /** + * Adds a specific line number to this set. + * + * @param line The line number (1-based) + */ + fun add(line: Int) { + bitSet.set(line) + } + + /** + * Adds a range of line numbers to this set. + * + * @param startLine the starting line number of the range to add (inclusive, 1-based) + * @param endLine the ending line number of the range to add (inclusive, 1-based) + */ + fun addRange(startLine: Int, endLine: Int) { + bitSet.set(startLine, endLine + 1) + } + + /** Removes a specific line number from this set. */ + fun remove(line: Int) { + bitSet.clear(line) + } + + /** + * Removes all line numbers that are present in another [CompactLines] instance from this one. + */ + fun removeAll(lines: CompactLines) { + bitSet.andNot(lines.bitSet) + } + + /** Clears all line numbers from this set. */ + fun clear() { + bitSet.clear() + } + + /** + * Retains only the line numbers that are present in both this and another [CompactLines] + * instance. This basically builds the intersection set between both. + */ + fun retainAll(lines: CompactLines) { + bitSet.and(lines.bitSet) + } + + /** + * Creates a new [CompactLines] object with the intersection of this and the other lines. + */ + fun intersection(other: CompactLines) = + compactLinesOf(this).apply { + retainAll(other) + } + + /** + * Checks if there is any overlap between the line numbers in this and another [CompactLines] + * instance. + * + * @return `true` if there is at least one common line number, `false` otherwise. + */ + fun intersects(lines: CompactLines) = + bitSet.intersects(lines.bitSet) + + override fun toString() = joinToString(",") + + /** + * Gets the highest line number contained in this set or null if there are no line numbers + * contained. + */ + fun getHighestLineNumber() = + if (bitSet.isEmpty) null else bitSet.previousSetBit(bitSet.length() - 1) + + @Throws(IOException::class) + private fun writeObject(out: ObjectOutputStream) { + val bytes = bitSet.toByteArray() + out.write(bytes) + } + + @Throws(IOException::class, ClassNotFoundException::class) + private fun readObject(`in`: ObjectInputStream) { + bitSet = BitSet.valueOf(`in`.readBytes()) + } + + override fun iterator(): Iterator { + return object : Iterator { + private var currentIndex = -1 + + override fun hasNext(): Boolean { + val nextIndex = bitSet.nextSetBit(currentIndex + 1) + return nextIndex != -1 + } + + override fun next(): Int { + if (!hasNext()) { + throw NoSuchElementException() + } + currentIndex = bitSet.nextSetBit(currentIndex + 1) + return currentIndex + } + } + } +} diff --git a/report-generator/src/main/kotlin/com/teamscale/report/util/ILogger.kt b/report-generator/src/main/kotlin/com/teamscale/report/util/ILogger.kt new file mode 100644 index 000000000..3af56f69f --- /dev/null +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/ILogger.kt @@ -0,0 +1,27 @@ +package com.teamscale.report.util + +/** + * Minimal logging interface. + * + * We use this to work around some strange problems when using log4j from the Teamscale Gradle plugin. + */ +interface ILogger { + + /** Logs at debug level. */ + fun debug(message: String) + + /** Logs at info level. */ + fun info(message: String) + + /** Logs at warning level. */ + fun warn(message: String) + + /** Logs at warning level. The given [Throwable] may be null. */ + fun warn(message: String, throwable: Throwable?) + + /** Logs at error level. */ + fun error(throwable: Throwable) + + /** Logs at error level. The given [Throwable] may be null. */ + fun error(message: String, throwable: Throwable? = null) +} diff --git a/report-generator/src/main/kotlin/org/jacoco/core/internal/analysis/CachingClassAnalyzer.kt b/report-generator/src/main/kotlin/org/jacoco/core/internal/analysis/CachingClassAnalyzer.kt new file mode 100644 index 000000000..2c4444603 --- /dev/null +++ b/report-generator/src/main/kotlin/org/jacoco/core/internal/analysis/CachingClassAnalyzer.kt @@ -0,0 +1,45 @@ +package org.jacoco.core.internal.analysis + +import com.teamscale.report.testwise.jacoco.cache.ClassCoverageLookup +import org.jacoco.core.internal.flow.MethodProbesVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.tree.MethodNode + +/** + * Analyzes a class to reconstruct probe information. + * + * + * A probe lookup holds for a single class which probe belongs to which lines. The actual filling of the + * [ClassCoverageLookup] happens in [CachingInstructionsBuilder]. + * + * @param classCoverageLookup cache for the class' probes + * @param coverage coverage node for the analyzed class data + * @param stringPool shared pool to minimize the number of [String] instances + */ +class CachingClassAnalyzer( + private val classCoverageLookup: ClassCoverageLookup, + coverage: ClassCoverageImpl, + stringPool: StringPool +) : ClassAnalyzer(coverage, null, stringPool) { + override fun visitSource(source: String?, debug: String?) { + super.visitSource(source, debug) + classCoverageLookup.sourceFileName = source + } + + override fun visitMethod( + access: Int, name: String?, + desc: String?, signature: String?, exceptions: Array? + ): MethodProbesVisitor { + val builder = CachingInstructionsBuilder(classCoverageLookup) + + return object : MethodAnalyzer(builder) { + override fun accept( + methodNode: MethodNode, + methodVisitor: MethodVisitor + ) { + super.accept(methodNode, methodVisitor) + builder.fillCache() + } + } + } +} diff --git a/report-generator/src/test/java/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.java b/report-generator/src/test/java/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.java deleted file mode 100644 index 99a630d14..000000000 --- a/report-generator/src/test/java/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.teamscale.report.jacoco; - -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.jacoco.dump.Dump; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import com.teamscale.test.TestDataBase; -import org.jacoco.core.data.ExecutionData; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.SessionInfo; -import org.jacoco.core.internal.data.CRC64; -import org.junit.jupiter.api.Test; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; - -/** Tests report generation with and without duplicate classes. */ -public class JaCoCoXmlReportGeneratorTest extends TestDataBase { - - /** Ensures that the normal case (no duplicated classes) runs without exceptions. */ - @Test - void testNormalCaseThrowsNoException() throws Exception { - try { - runGenerator("no-duplicates", EDuplicateClassFileBehavior.FAIL); - } catch (EmptyReportException e) { - // An empty report exception is thrown here because we did not configure the correct class IDs to match the - // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for - // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty - // reports here. Therefore we catch this exception to avoid letting the test fail because of it. - } - } - - /** Ensures that two identical duplicate classes do not cause problems. */ - @Test - void testIdenticalClassesShouldNotThrowException() throws Exception { - try { - runGenerator("identical-duplicate-classes", EDuplicateClassFileBehavior.FAIL); - } catch (EmptyReportException e) { - // An empty report exception is thrown here because we did not configure the correct class IDs to match the - // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for - // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty - // reports here. Therefore we catch this exception to avoid letting the test fail because of it. - } - } - - /** - * Ensures that two non-identical, duplicate classes cause an exception to be thrown. - */ - @Test - void testDifferentClassesWithTheSameNameShouldThrowException() { - assertThatThrownBy(() -> runGenerator("different-duplicate-classes", EDuplicateClassFileBehavior.FAIL)) - .isExactlyInstanceOf(IOException.class).hasCauseExactlyInstanceOf(IllegalStateException.class); - } - - /** - * Ensures that two non-identical, duplicate classes do not cause an exception to be thrown if the ignore-duplicates - * flag is set. - */ - @Test - void testDifferentClassesWithTheSameNameShouldNotThrowExceptionIfFlagIsSet() throws Exception { - try { - runGenerator("different-duplicate-classes", EDuplicateClassFileBehavior.IGNORE); - } catch (EmptyReportException e) { - // An empty report exception is thrown here because we did not configure the correct class IDs to match the - // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for - // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty - // reports here. Therefore we catch this exception to avoid letting the test fail because of it. - } - } - - @Test - void testEmptyCoverageFileThrowsException() throws IOException { - String testFolderName = "empty-report-handling"; - long classId = calculateClassId(testFolderName, "TestClass.class"); - assertThatThrownBy(() -> runGenerator(testFolderName, EDuplicateClassFileBehavior.IGNORE, false, - new ClasspathWildcardIncludeFilter("some.package.*", null), createDummyDump(classId))) - .isExactlyInstanceOf(EmptyReportException.class); - } - - @Test - void testNonEmptyCoverageFileDoesNotThrowException() throws IOException, EmptyReportException { - String testFolderName = "empty-report-handling"; - long classId = calculateClassId(testFolderName, "TestClass.class"); - runGenerator(testFolderName, EDuplicateClassFileBehavior.IGNORE, false, - new ClasspathWildcardIncludeFilter("*", null), createDummyDump(classId)); - } - - /** Ensures that uncovered classes are removed from the report if ignore-uncovered-classes is set. */ - @Test - void testShrinking() throws Exception { - String testFolderName = "ignore-uncovered-classes"; - long classId = calculateClassId(testFolderName, "TestClass.class"); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - - runGenerator(testFolderName, EDuplicateClassFileBehavior.FAIL, true, - new ClasspathWildcardIncludeFilter("*", null), - createDummyDump(classId)).copy(stream); - - String xmlString = stream.toString(StandardCharsets.UTF_8.name()); - assertThat(xmlString).contains("TestClass"); - assertThat(xmlString).doesNotContain("TestClassTwo"); - assertThat(xmlString).doesNotContain("ITestInterface"); - } - - /** Ensures that uncovered classes are contained in the report if ignore-uncovered-classes is not set. */ - @Test - void testNonShrinking() throws Exception { - String testFolderName = "ignore-uncovered-classes"; - long classId = calculateClassId(testFolderName, "TestClass.class"); - ByteArrayOutputStream stream = new ByteArrayOutputStream(); - - runGenerator(testFolderName, EDuplicateClassFileBehavior.FAIL, false, - new ClasspathWildcardIncludeFilter("*", null), - createDummyDump(classId)).copy(stream); - - String xmlString = stream.toString(StandardCharsets.UTF_8.name()); - assertThat(xmlString).contains("TestClassTwo"); - } - - /** - * Creates a dummy dump with the specified class ID. The class ID can currently be calculated with {@link - * org.jacoco.core.internal.data.CRC64#classId(byte[])}. This might change in the future, as it's considered an - * implementation detail of JaCoCo (c.f. - * https://www.jacoco.org/jacoco/trunk/doc/classids.html) - */ - private static Dump createDummyDump(long classId) { - ExecutionDataStore store = new ExecutionDataStore(); - store.put(new ExecutionData(classId, "TestClass", new boolean[]{true, true, true})); - SessionInfo info = new SessionInfo("session-id", 124L, 125L); - return new Dump(info, store); - } - - /** Creates a dummy dump with an arbitrary class ID. */ - private static Dump createDummyDump() { - return createDummyDump(123); - } - - private long calculateClassId(String testFolderName, String classFileName) throws IOException { - File classFile = useTestFile(testFolderName + File.separator + classFileName); - return CRC64.classId(Files.readAllBytes(classFile.toPath())); - } - - /** Runs the report generator with default values and without ignoring uncovered classes. */ - private CoverageFile runGenerator(String testDataFolder, - EDuplicateClassFileBehavior duplicateClassFileBehavior) throws Exception, EmptyReportException { - return runGenerator(testDataFolder, duplicateClassFileBehavior, false, new ClasspathWildcardIncludeFilter(null, null), - createDummyDump()); - } - - private CoverageFile runGenerator(String testDataFolder, - EDuplicateClassFileBehavior duplicateClassFileBehavior, boolean ignoreUncoveredClasses, - ClasspathWildcardIncludeFilter filter, - Dump dump) throws IOException, EmptyReportException { - File classFileFolder = useTestFile(testDataFolder); - long currentTime = System.currentTimeMillis(); - String outputFilePath = "test-coverage-" + currentTime + ".xml"; - return new JaCoCoXmlReportGenerator(Collections.singletonList(classFileFolder), filter, - duplicateClassFileBehavior, ignoreUncoveredClasses, - mock(ILogger.class)).convert(dump, Paths.get(outputFilePath).toFile()); - } -} diff --git a/report-generator/src/test/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.java b/report-generator/src/test/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.java deleted file mode 100644 index 88a3f1741..000000000 --- a/report-generator/src/test/java/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.teamscale.report.testwise.jacoco; - -import com.teamscale.client.TestDetails; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.ReportUtils; -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestwiseCoverage; -import com.teamscale.report.testwise.model.TestwiseCoverageReport; -import com.teamscale.report.testwise.model.builder.TestCoverageBuilder; -import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.ILogger; -import com.teamscale.test.TestDataBase; -import org.conqat.lib.commons.filesystem.FileSystemUtils; -import org.junit.jupiter.api.Test; -import org.skyscreamer.jsonassert.JSONAssert; -import org.skyscreamer.jsonassert.JSONCompareMode; - -import java.io.File; -import java.util.ArrayList; -import java.util.Collections; - -import static org.mockito.Mockito.mock; - -/** Tests for the {@link JaCoCoTestwiseReportGenerator} class. */ -public class JaCoCoTestwiseReportGeneratorTest extends TestDataBase { - - @Test - void testSmokeTestTestwiseReportGeneration() throws Exception { - String report = runReportGenerator("jacoco/cqddl/classes.zip", "jacoco/cqddl/coverage.exec"); - String expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/cqddl/report.json.expected")); - JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT); - } - - @Test - void testSampleTestwiseReportGeneration() throws Exception { - String report = runReportGenerator("jacoco/sample/classes.zip", "jacoco/sample/coverage.exec"); - String expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/sample/report.json.expected")); - JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT); - } - - @Test - void defaultPackageIsHandledAsEmptyPath() throws Exception { - String report = runReportGenerator("jacoco/default-package/classes.zip", "jacoco/default-package/coverage.exec"); - String expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/default-package/report.json.expected")); - JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT); - } - - private String runReportGenerator(String testDataFolder, String execFileName) throws Exception { - File classFileFolder = useTestFile(testDataFolder); - ClasspathWildcardIncludeFilter includeFilter = new ClasspathWildcardIncludeFilter(null, null); - TestwiseCoverage testwiseCoverage = new JaCoCoTestwiseReportGenerator( - Collections.singletonList(classFileFolder), - includeFilter, EDuplicateClassFileBehavior.IGNORE, - mock(ILogger.class)).convert(useTestFile(execFileName)); - return ReportUtils.getTestwiseCoverageReportAsString(generateDummyReportFrom(testwiseCoverage)); - } - - /** Generates a dummy coverage report object that wraps the given {@link TestwiseCoverage}. */ - public static TestwiseCoverageReport generateDummyReportFrom(TestwiseCoverage testwiseCoverage) { - ArrayList testDetails = new ArrayList<>(); - for (TestCoverageBuilder test : testwiseCoverage.getTests()) { - testDetails.add(new TestDetails(test.getUniformPath(), "/path/to/source", "content")); - } - ArrayList testExecutions = new ArrayList<>(); - for (TestCoverageBuilder test : testwiseCoverage.getTests()) { - testExecutions.add(new TestExecution(test.getUniformPath(), test.getUniformPath().length(), - ETestExecutionResult.PASSED)); - } - return TestwiseCoverageReportBuilder.createFrom(testDetails, testwiseCoverage.getTests(), testExecutions, true); - } - -} \ No newline at end of file diff --git a/report-generator/src/test/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.java b/report-generator/src/test/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.java deleted file mode 100644 index d76456484..000000000 --- a/report-generator/src/test/java/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.teamscale.report.testwise.model.builder; - -import com.teamscale.report.testwise.model.LineRange; -import com.teamscale.report.util.SortedIntList; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; -import static org.junit.jupiter.api.Assertions.assertEquals; - -/** Tests the {@link FileCoverageBuilder} class. */ -class FileCoverageBuilderTest { - - /** Tests the compactification algorithm for line ranges. */ - @Test - void compactifyRanges() { - SortedIntList sortedIntList = new SortedIntList(); - sortedIntList.add(1); - sortedIntList.add(3); - sortedIntList.add(4); - sortedIntList.add(6); - sortedIntList.add(7); - sortedIntList.add(10); - List result = FileCoverageBuilder.compactifyToRanges(sortedIntList); - assertThat(result).hasToString("[1, 3-4, 6-7, 10]"); - } - - /** Tests the merge of two {@link FileCoverageBuilder} objects. */ - @Test - void mergeDoesMergeRanges() { - FileCoverageBuilder fileCoverage = new FileCoverageBuilder("path", "file"); - fileCoverage.addLine(1); - fileCoverage.addLineRange(3, 4); - fileCoverage.addLineRange(7, 10); - - FileCoverageBuilder otherFileCoverage = new FileCoverageBuilder("path", "file"); - fileCoverage.addLineRange(1, 3); - fileCoverage.addLineRange(12, 14); - fileCoverage.merge(otherFileCoverage); - assertThat(fileCoverage.computeCompactifiedRangesAsString()).isEqualTo("1-4,7-10,12-14"); - } - - /** Tests that two {@link FileCoverageBuilder} objects from different files throws an exception. */ - @Test - void mergeDoesNotAllowMergeOfTwoDifferentFiles() { - FileCoverageBuilder fileCoverage = new FileCoverageBuilder("path", "file"); - fileCoverage.addLine(1); - - FileCoverageBuilder otherFileCoverage = new FileCoverageBuilder("path", "file2"); - fileCoverage.addLineRange(1, 3); - assertThatCode(() -> fileCoverage.merge(otherFileCoverage)).isInstanceOf(AssertionError.class); - } - - /** Tests the transformation from line ranges into its string representation. */ - @Test - void getRangesAsString() { - FileCoverageBuilder fileCoverage = new FileCoverageBuilder("path", "file"); - fileCoverage.addLine(1); - fileCoverage.addLineRange(3, 4); - fileCoverage.addLineRange(6, 10); - assertEquals("1,3-4,6-10", fileCoverage.computeCompactifiedRangesAsString()); - } -} \ No newline at end of file diff --git a/report-generator/src/test/java/com/teamscale/report/util/BashFileSkippingInputStreamTest.java b/report-generator/src/test/java/com/teamscale/report/util/BashFileSkippingInputStreamTest.java deleted file mode 100644 index 7e429b8a8..000000000 --- a/report-generator/src/test/java/com/teamscale/report/util/BashFileSkippingInputStreamTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.teamscale.report.util; - -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.jar.JarEntry; -import java.util.jar.JarInputStream; - -import static org.assertj.core.api.Assertions.assertThat; - -class BashFileSkippingInputStreamTest { - - @Test - void testBashFileJar() throws IOException { - ArrayList filesInJar = getEntriesFromJarFile("spring-boot-executable-example.jar"); - assertThat(filesInJar).hasSize(110); - } - - @Test - void testNormalJar() throws IOException { - ArrayList filesInJar = getEntriesFromJarFile("normal.jar"); - assertThat(filesInJar).hasSize(284); - } - - private ArrayList getEntriesFromJarFile(String resourceName) throws IOException { - InputStream inputStream = getClass().getResourceAsStream(resourceName); - BashFileSkippingInputStream bashFileSkippingInputStream = new BashFileSkippingInputStream(inputStream); - JarInputStream jarInputStream = new JarInputStream(bashFileSkippingInputStream); - JarEntry entry; - ArrayList filesInJar = new ArrayList<>(); - while ((entry = jarInputStream.getNextJarEntry()) != null) { - filesInJar.add(entry.getName()); - } - return filesInJar; - } -} diff --git a/report-generator/src/test/java/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.java b/report-generator/src/test/java/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.java deleted file mode 100644 index 95567b9c3..000000000 --- a/report-generator/src/test/java/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.teamscale.report.util; - -import org.junit.jupiter.api.Test; - -import static com.teamscale.report.util.ClasspathWildcardIncludeFilter.getClassName; -import static org.assertj.core.api.Assertions.assertThat; - -public class ClasspathWildcardIncludeFilterTest { - - /** Tests path to class name conversion. */ - @Test - void testPathToClassNameConversion() { - assertThat(getClassName("file.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar"); - assertThat(getClassName("file.jar@com/foo/Bar$Goo.class")).isEqualTo("com.foo.Bar.Goo"); - assertThat(getClassName("file1.jar@goo/file2.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar"); - assertThat(getClassName("com/foo/Bar.class")).isEqualTo("com.foo.Bar"); - assertThat(getClassName("com/foo/Bar")).isEqualTo("com.foo.Bar"); - assertThat(getClassName( - "C:\\client-daily\\client\\plugins\\com.customer.something.client_1.2.3.4.1234566778.jar@com/customer/something/SomeClass.class")) - .isEqualTo("com.customer.something.SomeClass"); - } - - - @Test - void testMatching() { - assertThat(new ClasspathWildcardIncludeFilter(null, "org.junit.*") - .isIncluded("/junit-jupiter-engine-5.1.0.jar@org/junit/jupiter/engine/Constants.class")).isFalse(); - assertThat(new ClasspathWildcardIncludeFilter(null, "org.junit.*") - .isIncluded("org/junit/platform/commons/util/ModuleUtils$ModuleReferenceScanner.class")).isFalse(); - } -} \ No newline at end of file diff --git a/report-generator/src/test/java/com/teamscale/report/util/SortedIntListTest.java b/report-generator/src/test/java/com/teamscale/report/util/SortedIntListTest.java deleted file mode 100644 index f759978a3..000000000 --- a/report-generator/src/test/java/com/teamscale/report/util/SortedIntListTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.teamscale.report.util; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class SortedIntListTest { - - @Test - void emptyList() { - SortedIntList sortedIntList = new SortedIntList(); - assertThat(sortedIntList.isEmpty()).isTrue(); - } - - @Test - void addSorted() { - SortedIntList sortedIntList = listOf(1, 3, 4, 7, 10); - assertThat(sortedIntList.list).startsWith(1, 3, 4, 7, 10); - assertThat(sortedIntList.size()).isEqualTo(5); - } - - @Test - void addReversed() { - SortedIntList sortedIntList = listOf(6, 5, 2, 0); - assertThat(sortedIntList.list).startsWith(0, 2, 5, 6); - assertThat(sortedIntList.size()).isEqualTo(4); - } - - @Test - void add() { - SortedIntList sortedIntList = listOf(7, 4, 9, 11, 1); - assertThat(sortedIntList.list).startsWith(1, 4, 7, 9, 11); - assertThat(sortedIntList.size()).isEqualTo(5); - } - - @Test - void mergeIntoEmptyList() { - SortedIntList sortedIntList = listOf(); - sortedIntList.addAll(listOf(1, 2, 5, 8, 9)); - assertThat(sortedIntList.list).startsWith(1, 2, 5, 8, 9); - assertThat(sortedIntList.size()).isEqualTo(5); - } - - @Test - void mergeWithEmptyList() { - SortedIntList sortedIntList = listOf(1, 2, 5, 8, 9); - sortedIntList.addAll(listOf()); - assertThat(sortedIntList.list).startsWith(1, 2, 5, 8, 9); - assertThat(sortedIntList.size()).isEqualTo(5); - } - - @Test - void mergeWithOverlap() { - SortedIntList sortedIntList = listOf(1, 2, 5, 8, 9); - sortedIntList.addAll(listOf(3, 4, 5)); - assertThat(sortedIntList.list).startsWith(1, 2, 3, 4, 5, 8, 9); - assertThat(sortedIntList.size()).isEqualTo(7); - } - - private SortedIntList listOf(int... values) { - SortedIntList sortedIntList = new SortedIntList(); - for (int value : values) { - sortedIntList.add(value); - } - return sortedIntList; - } -} \ No newline at end of file diff --git a/report-generator/src/test/java/com/teamscale/test/TestDataBase.java b/report-generator/src/test/java/com/teamscale/test/TestDataBase.java deleted file mode 100644 index c54319b5b..000000000 --- a/report-generator/src/test/java/com/teamscale/test/TestDataBase.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.teamscale.test; - -import java.io.File; - -/** Base class that supports reading test-data files. */ -public class TestDataBase { - - /** Read the given test-data file in the context of the current class's package. */ - protected File useTestFile(String fileName) { - return new File(new File("test-data", getClass().getPackage().getName()), fileName); - } -} diff --git a/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt new file mode 100644 index 000000000..3b8495eb3 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGeneratorTest.kt @@ -0,0 +1,181 @@ +package com.teamscale.report.jacoco + +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.jacoco.dump.Dump +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.test.TestDataBase +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.jacoco.core.data.ExecutionData +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.SessionInfo +import org.jacoco.core.internal.data.CRC64 +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +/** Tests report generation with and without duplicate classes. */ +class JaCoCoXmlReportGeneratorTest : TestDataBase() { + /** Ensures that the normal case (no duplicated classes) runs without exceptions. */ + @Test + fun testNormalCaseThrowsNoException() { + try { + runGenerator("no-duplicates", EDuplicateClassFileBehavior.FAIL) + } catch (e: EmptyReportException) { + // An empty report exception is thrown here because we did not configure the correct class IDs to match the + // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for + // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty + // reports here. Therefore we catch this exception to avoid letting the test fail because of it. + } + } + + /** Ensures that two identical duplicate classes do not cause problems. */ + @Test + fun testIdenticalClassesShouldNotThrowException() { + try { + runGenerator("identical-duplicate-classes", EDuplicateClassFileBehavior.FAIL) + } catch (e: EmptyReportException) { + // An empty report exception is thrown here because we did not configure the correct class IDs to match the + // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for + // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty + // reports here. Therefore we catch this exception to avoid letting the test fail because of it. + } + } + + /** + * Ensures that two non-identical, duplicate classes cause an exception to be thrown. + */ + @Test + fun testDifferentClassesWithTheSameNameShouldThrowException() { + assertThatThrownBy { + runGenerator( + "different-duplicate-classes", + EDuplicateClassFileBehavior.FAIL + ) + }.isExactlyInstanceOf(IOException::class.java) + .hasCauseExactlyInstanceOf(IllegalStateException::class.java) + } + + /** + * Ensures that two non-identical, duplicate classes do not cause an exception to be thrown if the ignore-duplicates + * flag is set. + */ + @Test + fun testDifferentClassesWithTheSameNameShouldNotThrowExceptionIfFlagIsSet() { + try { + runGenerator("different-duplicate-classes", EDuplicateClassFileBehavior.IGNORE) + } catch (e: EmptyReportException) { + // An empty report exception is thrown here because we did not configure the correct class IDs to match the + // artificial coverage generated by createDummyDump to the actual class file. Since we are only testing for + // IllegalStateExceptions resulting from duplicated, non-identical class files, and don't care about empty + // reports here. Therefore we catch this exception to avoid letting the test fail because of it. + } + } + + @Test + fun testEmptyCoverageFileThrowsException() { + val testFolderName = "empty-report-handling" + val classId = calculateClassId(testFolderName, "TestClass.class") + assertThatThrownBy { + runGenerator( + testFolderName, + EDuplicateClassFileBehavior.IGNORE, + false, + ClasspathWildcardIncludeFilter("some.package.*", null), + createDummyDump(classId) + ) + }.isExactlyInstanceOf(EmptyReportException::class.java) + } + + @Test + @Throws(IOException::class, EmptyReportException::class) + fun testNonEmptyCoverageFileDoesNotThrowException() { + val testFolderName = "empty-report-handling" + val classId = calculateClassId(testFolderName, "TestClass.class") + runGenerator( + testFolderName, EDuplicateClassFileBehavior.IGNORE, false, + ClasspathWildcardIncludeFilter("*", null), createDummyDump(classId) + ) + } + + /** Ensures that uncovered classes are removed from the report if ignore-uncovered-classes is set. */ + @Test + fun testShrinking() { + val testFolderName = "ignore-uncovered-classes" + val classId = calculateClassId(testFolderName, "TestClass.class") + val stream = ByteArrayOutputStream() + + runGenerator( + testFolderName, EDuplicateClassFileBehavior.FAIL, true, + ClasspathWildcardIncludeFilter("*", null), + createDummyDump(classId) + ).copyStream(stream) + + val xmlString = stream.toString(StandardCharsets.UTF_8.name()) + assertThat(xmlString).contains("TestClass") + assertThat(xmlString).doesNotContain("TestClassTwo") + assertThat(xmlString).doesNotContain("ITestInterface") + } + + /** Ensures that uncovered classes are contained in the report if ignore-uncovered-classes is not set. */ + @Test + fun testNonShrinking() { + val testFolderName = "ignore-uncovered-classes" + val classId = calculateClassId(testFolderName, "TestClass.class") + val stream = ByteArrayOutputStream() + + runGenerator( + testFolderName, EDuplicateClassFileBehavior.FAIL, false, + ClasspathWildcardIncludeFilter("*", null), + createDummyDump(classId) + ).copyStream(stream) + + val xmlString = stream.toString(StandardCharsets.UTF_8.name()) + assertThat(xmlString).contains("TestClassTwo") + } + + @Throws(IOException::class) + private fun calculateClassId(testFolderName: String, classFileName: String): Long { + val classFile = useTestFile(testFolderName + File.separator + classFileName) + return CRC64.classId(Files.readAllBytes(classFile.toPath())) + } + + /** Runs the report generator with default values and without ignoring uncovered classes. */ + @Throws(IOException::class, EmptyReportException::class) + private fun runGenerator( + testDataFolder: String, + duplicateClassFileBehavior: EDuplicateClassFileBehavior, + ignoreUncoveredClasses: Boolean = false, + filter: ClasspathWildcardIncludeFilter = ClasspathWildcardIncludeFilter(null, null), + dump: Dump = createDummyDump() + ): CoverageFile { + val classFileFolder = useTestFile(testDataFolder) + val currentTime = System.currentTimeMillis() + val outputFilePath = "test-coverage-$currentTime.xml" + return JaCoCoXmlReportGenerator( + listOf(classFileFolder), + filter, + duplicateClassFileBehavior, + ignoreUncoveredClasses, + Mockito.mock() + ).convert(dump, Paths.get(outputFilePath).toFile()) + } + + companion object { + /** + * Creates a fake dump with the specified class ID. The class ID can currently be calculated with [org.jacoco.core.internal.data.CRC64.classId]. + * This might change in the future, as it's considered an [implementation detail of JaCoCo](https://www.jacoco.org/jacoco/trunk/doc/classids.html)) + */ + private fun createDummyDump(classId: Long = 123): Dump { + val store = ExecutionDataStore() + store.put(ExecutionData(classId, "TestClass", booleanArrayOf(true, true, true))) + val info = SessionInfo("session-id", 124L, 125L) + return Dump(info, store) + } + } +} diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.kt new file mode 100644 index 000000000..792f90d8a --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/jacoco/JaCoCoTestwiseReportGeneratorTest.kt @@ -0,0 +1,69 @@ +package com.teamscale.report.testwise.jacoco + +import com.teamscale.client.TestDetails +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.ReportUtils.getTestwiseCoverageReportAsString +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestwiseCoverage +import com.teamscale.report.testwise.model.TestwiseCoverageReport +import com.teamscale.report.testwise.model.builder.TestwiseCoverageReportBuilder.Companion.createFrom +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.test.TestDataBase +import org.conqat.lib.commons.filesystem.FileSystemUtils +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.skyscreamer.jsonassert.JSONAssert +import org.skyscreamer.jsonassert.JSONCompareMode + +/** Tests for the [JaCoCoTestwiseReportGenerator] class. */ +class JaCoCoTestwiseReportGeneratorTest : TestDataBase() { + @Test + fun testSmokeTestTestwiseReportGeneration() { + val report = runReportGenerator("jacoco/cqddl/classes.zip", "jacoco/cqddl/coverage.exec") + val expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/cqddl/report.json.expected")) + JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT) + } + + @Test + fun testSampleTestwiseReportGeneration() { + val report = runReportGenerator("jacoco/sample/classes.zip", "jacoco/sample/coverage.exec") + val expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/sample/report.json.expected")) + JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT) + } + + @Test + fun defaultPackageIsHandledAsEmptyPath() { + val report = runReportGenerator("jacoco/default-package/classes.zip", "jacoco/default-package/coverage.exec") + val expected = FileSystemUtils.readFileUTF8(useTestFile("jacoco/default-package/report.json.expected")) + JSONAssert.assertEquals(expected, report, JSONCompareMode.STRICT) + } + + @Throws(Exception::class) + private fun runReportGenerator(testDataFolder: String, execFileName: String): String { + val classFileFolder = useTestFile(testDataFolder) + val includeFilter = ClasspathWildcardIncludeFilter(null, null) + val testwiseCoverage = JaCoCoTestwiseReportGenerator( + listOf(classFileFolder), + includeFilter, EDuplicateClassFileBehavior.IGNORE, + Mockito.mock() + ).convert(useTestFile(execFileName)) + return getTestwiseCoverageReportAsString(testwiseCoverage.generateDummyReport()) + } + + companion object { + /** Generates a fake coverage report object that wraps the given [TestwiseCoverage]. */ + fun TestwiseCoverage.generateDummyReport(): TestwiseCoverageReport { + val testDetails = tests.values.map { + TestDetails(it.uniformPath, "/path/to/source", "content") + } + val testExecutions = tests.values.map { + TestExecution( + it.uniformPath, it.uniformPath.length.toLong(), + ETestExecutionResult.PASSED + ) + } + return createFrom(testDetails, tests.values, testExecutions, true) + } + } +} \ No newline at end of file diff --git a/report-generator/src/test/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.kt new file mode 100644 index 000000000..fa48fb125 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/testwise/model/builder/FileCoverageBuilderTest.kt @@ -0,0 +1,62 @@ +package com.teamscale.report.testwise.model.builder + +import com.teamscale.report.testwise.model.builder.FileCoverageBuilder.Companion.compactifyToRanges +import com.teamscale.report.util.CompactLines.Companion.compactLinesOf +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** Tests the [FileCoverageBuilder] class. */ +internal class FileCoverageBuilderTest { + /** Tests the compactification algorithm for line ranges. */ + @Test + fun compactifyRanges() { + val compactLines = compactLinesOf(1, 3, 4, 6, 7, 10) + assertThat(compactifyToRanges(compactLines)) + .hasToString("[1, 3-4, 6-7, 10]") + } + + /** Tests the merge of two [FileCoverageBuilder] objects. */ + @Test + fun mergeDoesMergeRanges() { + FileCoverageBuilder("path", "file").apply { + addLine(1) + addLineRange(3, 4) + addLineRange(7, 10) + + addLineRange(1, 3) + addLineRange(12, 14) + val otherFileCoverage = FileCoverageBuilder("path", "file") + merge(otherFileCoverage) + assertThat(computeCompactifiedRangesAsString()).isEqualTo("1-4,7-10,12-14") + } + } + + /** Tests that two [FileCoverageBuilder] objects from different files throws an exception. */ + @Test + fun mergeDoesNotAllowMergeOfTwoDifferentFiles() { + FileCoverageBuilder("path", "file").apply { + addLine(1) + addLineRange(1, 3) + val otherFileCoverage = FileCoverageBuilder("path", "file2") + assertThatCode { + merge(otherFileCoverage) + }.isInstanceOf(IllegalArgumentException::class.java) + } + } + + @Test + /** Tests the transformation from line ranges into its string representation. */ + fun getRangesAsString() { + FileCoverageBuilder("path", "file").apply { + addLine(1) + addLineRange(3, 4) + addLineRange(6, 10) + assertEquals( + "1,3-4,6-10", + computeCompactifiedRangesAsString() + ) + } + } +} \ No newline at end of file diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/BashFileSkippingInputStreamTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/BashFileSkippingInputStreamTest.kt new file mode 100644 index 000000000..93719bbfd --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/BashFileSkippingInputStreamTest.kt @@ -0,0 +1,33 @@ +package com.teamscale.report.util + +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.jar.JarEntry +import java.util.jar.JarInputStream + +internal class BashFileSkippingInputStreamTest { + @Test + @Throws(IOException::class) + fun testBashFileJar() { + val filesInJar = getEntriesFromJarFile("spring-boot-executable-example.jar") + Assertions.assertThat(filesInJar).hasSize(110) + } + + @Test + @Throws(IOException::class) + fun testNormalJar() { + val filesInJar = getEntriesFromJarFile("normal.jar") + Assertions.assertThat(filesInJar).hasSize(284) + } + + @Throws(IOException::class) + private fun getEntriesFromJarFile(resourceName: String): List { + val inputStream = javaClass.getResourceAsStream(resourceName) + val bashFileSkippingInputStream = BashFileSkippingInputStream(inputStream!!) + val jarInputStream = JarInputStream(bashFileSkippingInputStream) + return generateSequence { jarInputStream.nextJarEntry } + .map { it.name } + .toList() + } +} diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt new file mode 100644 index 000000000..fa7d3b076 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/ClasspathWildcardIncludeFilterTest.kt @@ -0,0 +1,36 @@ +package com.teamscale.report.util + +import com.teamscale.report.util.ClasspathWildcardIncludeFilter.Companion.getClassName +import org.assertj.core.api.Assertions +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ClasspathWildcardIncludeFilterTest { + /** Tests path to class name conversion. */ + @Test + fun testPathToClassNameConversion() { + assertThat(getClassName("file.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar") + assertThat(getClassName("file.jar@com/foo/Bar\$Goo.class")).isEqualTo("com.foo.Bar.Goo") + assertThat(getClassName("file1.jar@goo/file2.jar@com/foo/Bar.class")).isEqualTo("com.foo.Bar") + assertThat(getClassName("com/foo/Bar.class")).isEqualTo("com.foo.Bar") + assertThat(getClassName("com/foo/Bar")).isEqualTo("com.foo.Bar") + assertThat( + getClassName( + "C:\\client-daily\\client\\plugins\\com.customer.something.client_1.2.3.4.1234566778.jar@com/customer/something/SomeClass.class" + ) + ).isEqualTo("com.customer.something.SomeClass") + } + + + @Test + fun testMatching() { + assertThat( + ClasspathWildcardIncludeFilter(null, "org.junit.*") + .isIncluded("/junit-jupiter-engine-5.1.0.jar@org/junit/jupiter/engine/Constants.class") + ).isFalse() + assertThat( + ClasspathWildcardIncludeFilter(null, "org.junit.*") + .isIncluded("org/junit/platform/commons/util/ModuleUtils\$ModuleReferenceScanner.class") + ).isFalse() + } +} \ No newline at end of file diff --git a/report-generator/src/test/kotlin/com/teamscale/report/util/CompactLinesTest.kt b/report-generator/src/test/kotlin/com/teamscale/report/util/CompactLinesTest.kt new file mode 100644 index 000000000..0da4fb3f4 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/report/util/CompactLinesTest.kt @@ -0,0 +1,122 @@ +package com.teamscale.report.util + +import com.teamscale.report.util.CompactLines.Companion.compactLinesOf +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +internal class CompactLinesTest { + @Test + fun emptyList() { + val compactLines = compactLinesOf() + assertThat(compactLines.isEmpty()).isTrue() + } + + @Test + fun testContains() { + val lines = compactLinesOf(5) + assertThat(lines.contains(5)).isTrue() + assertThat(lines.contains(4)).isFalse() + } + + @Test + fun testAddAndRemove() { + val lines = compactLinesOf() + lines.add(10) + assertThat(lines.contains(10)).isTrue() + lines.remove(10) + assertThat(lines.contains(10)).isFalse() + } + + @Test + fun testSize() { + val lines = compactLinesOf(1, 2) + assertThat(lines.size()).isEqualTo(2) + } + + @Test + fun testIsEmpty() { + val lines = compactLinesOf() + assertThat(lines.isEmpty()).isTrue() + lines.add(1) + assertThat(lines.isEmpty()).isFalse() + } + + @Test + fun testMerging() { + val lines1 = compactLinesOf(1, 2) + val lines2 = compactLinesOf() + lines2 merge lines1 + + assertThat(lines2.contains(1)).isTrue() + assertThat(lines2.contains(2)).isTrue() + } + + @Test + fun testRemoveAll() { + val lines1 = compactLinesOf(1, 2) + val lines2 = compactLinesOf(1, 2, 3) + lines2.removeAll(lines1) + + assertThat(lines2.contains(1)).isFalse() + assertThat(lines2.contains(2)).isFalse() + assertThat(lines2.contains(3)).isTrue() + } + + @Test + fun testIntersects() { + val lines1 = compactLinesOf(1, 2) + val lines2 = compactLinesOf(2, 3) + + assertThat(lines1.intersects(lines2)).isTrue() + + lines2.remove(2) + assertThat(lines1.intersects(lines2)).isFalse() + } + + @Test + fun testContainsAny() { + val lines = compactLinesOf(5, 10) + + assertThat(lines.containsAny(3, 4)).isFalse() + assertThat(lines.containsAny(3, 5)).isTrue() + assertThat(lines.containsAny(4, 6)).isTrue() + assertThat(lines.containsAny(10, 15)).isTrue() + assertThat(lines.containsAny(11, 15)).isFalse() + } + + @Test + fun testAddRange() { + val lines = compactLinesOf() + lines.addRange(5, 7) + assertThat(lines).containsExactly(5, 6, 7) + } + + @Test + fun testContainsAllTrue() { + val lines = compactLinesOf(1, 3) + assertThat(lines.containsAll(listOf(1, 2, 3))).isFalse() + assertThat(lines.containsAll(listOf(1, 3))).isTrue() + assertThat(lines.containsAll(compactLinesOf(1, 2, 3))).isFalse() + assertThat(lines.containsAll(compactLinesOf(1, 3))).isTrue() + } + + @Test + fun testSerialization() { + val lines = compactLinesOf(1, 3, 7) + + val bytes = ByteArrayOutputStream().use { outputStream -> + ObjectOutputStream(outputStream).use { it.writeObject(lines) } + outputStream.toByteArray() + } + + val deserializedLines = ByteArrayInputStream(bytes).use { inputStream -> + ObjectInputStream(inputStream).use { it.readObject() as CompactLines } + } + + assertThat(deserializedLines).containsExactly(1, 3, 7) + } +} \ No newline at end of file diff --git a/report-generator/src/test/kotlin/com/teamscale/test/TestDataBase.kt b/report-generator/src/test/kotlin/com/teamscale/test/TestDataBase.kt new file mode 100644 index 000000000..b9737e5a8 --- /dev/null +++ b/report-generator/src/test/kotlin/com/teamscale/test/TestDataBase.kt @@ -0,0 +1,10 @@ +package com.teamscale.test + +import java.io.File + +/** Base class that supports reading test-data files. */ +open class TestDataBase { + /** Read the given test-data file in the context of the current class's package. */ + protected fun useTestFile(fileName: String) = + File(File("test-data", javaClass.getPackage().name), fileName) +} diff --git a/sample-debugging-app/build.gradle.kts b/sample-debugging-app/build.gradle.kts index d4752cbbb..c59e727da 100644 --- a/sample-debugging-app/build.gradle.kts +++ b/sample-debugging-app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { com.teamscale.`java-convention` application + com.teamscale.`agent-jar` } application { diff --git a/settings.gradle.kts b/settings.gradle.kts index ff40862d7..dfdf27495 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,11 @@ +pluginManagement { + plugins { + kotlin("jvm") version "2.1.0" + } +} + plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version("0.8.0") + id("org.gradle.toolchains.foojay-resolver-convention") version("0.9.0") } include(":agent") diff --git a/system-tests/artifactory-git-properties-detection/src/main/resources/git.properties b/system-tests/artifactory-git-properties-detection/src/main/resources/git.properties deleted file mode 100644 index f54c8fc31..000000000 --- a/system-tests/artifactory-git-properties-detection/src/main/resources/git.properties +++ /dev/null @@ -1,3 +0,0 @@ -git.commit.id=86f9d655bf8a204d98bc3542e0d15cea38cc7c74 -git.branch=master -git.commit.time=2022-02-24T15:43:23+0100 diff --git a/system-tests/artifactory-git-properties-detection/src/main/resources/jar-with-properties.jar b/system-tests/artifactory-git-properties-detection/src/main/resources/jar-with-properties.jar new file mode 100644 index 000000000..643746d93 Binary files /dev/null and b/system-tests/artifactory-git-properties-detection/src/main/resources/jar-with-properties.jar differ diff --git a/system-tests/cucumber-maven-tia/build.gradle.kts b/system-tests/cucumber-maven-tia/build.gradle.kts index 969326618..10a3c21f0 100644 --- a/system-tests/cucumber-maven-tia/build.gradle.kts +++ b/system-tests/cucumber-maven-tia/build.gradle.kts @@ -4,8 +4,6 @@ plugins { } tasks.test { - environment("AGENT_VERSION", version) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Maven test project dependsOn(rootProject.tasks["publishToMavenLocal"]) } diff --git a/system-tests/gradle-cucumber/build.gradle.kts b/system-tests/gradle-cucumber/build.gradle.kts index 1fe8e1c00..394ee070b 100644 --- a/system-tests/gradle-cucumber/build.gradle.kts +++ b/system-tests/gradle-cucumber/build.gradle.kts @@ -4,8 +4,6 @@ plugins { } tasks.test { - environment("AGENT_VERSION", version) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Gradle test project dependsOn(rootProject.tasks["publishToMavenLocal"]) } diff --git a/system-tests/gradle-multi-module/build.gradle.kts b/system-tests/gradle-multi-module/build.gradle.kts index 1fe8e1c00..394ee070b 100644 --- a/system-tests/gradle-multi-module/build.gradle.kts +++ b/system-tests/gradle-multi-module/build.gradle.kts @@ -4,8 +4,6 @@ plugins { } tasks.test { - environment("AGENT_VERSION", version) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Gradle test project dependsOn(rootProject.tasks["publishToMavenLocal"]) } diff --git a/system-tests/junit-run-listener-test/build.gradle.kts b/system-tests/junit-run-listener-test/build.gradle.kts index 3a326a754..a983093a7 100644 --- a/system-tests/junit-run-listener-test/build.gradle.kts +++ b/system-tests/junit-run-listener-test/build.gradle.kts @@ -4,10 +4,6 @@ plugins { } tasks.test { - environment("AGENT_PATH", agentJar) - environment("AGENT_VERSION", version) - environment("AGENT_PORT", agentPort) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Maven test projects dependsOn(rootProject.tasks["publishToMavenLocal"]) } diff --git a/system-tests/kotlin-inline-function-test/build.gradle.kts b/system-tests/kotlin-inline-function-test/build.gradle.kts index df45d8731..fefee92b2 100644 --- a/system-tests/kotlin-inline-function-test/build.gradle.kts +++ b/system-tests/kotlin-inline-function-test/build.gradle.kts @@ -1,6 +1,6 @@ plugins { com.teamscale.`system-test-convention` - kotlin("jvm") version "1.9.24" + kotlin("jvm") } tasks.test { diff --git a/system-tests/log-version-on-startup-test/build.gradle.kts b/system-tests/log-version-on-startup-test/build.gradle.kts index 5770e2287..a1473cb5e 100644 --- a/system-tests/log-version-on-startup-test/build.gradle.kts +++ b/system-tests/log-version-on-startup-test/build.gradle.kts @@ -4,6 +4,5 @@ plugins { tasks.test { val logFilePath = "logTest" - environment("AGENT_VERSION", rootProject.extra["appVersion"].toString()) teamscaleAgent(mapOf("debug" to logFilePath)) } diff --git a/system-tests/maven-external-upload-test/build.gradle.kts b/system-tests/maven-external-upload-test/build.gradle.kts index 8acf7590f..1eaa62be2 100644 --- a/system-tests/maven-external-upload-test/build.gradle.kts +++ b/system-tests/maven-external-upload-test/build.gradle.kts @@ -3,8 +3,6 @@ plugins { } tasks.test { - environment("AGENT_VERSION", rootProject.extra["appVersion"].toString()) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Maven test project dependsOn(":publishToMavenLocal") } diff --git a/system-tests/maven-external-upload-test/missing-commit-project/.gitignore b/system-tests/maven-external-upload-test/missing-commit-project/.gitignore new file mode 100644 index 000000000..c5078494e --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/.gitignore @@ -0,0 +1,2 @@ +target +.idea diff --git a/system-tests/maven-external-upload-test/missing-commit-project/.mvn/wrapper/maven-wrapper.properties b/system-tests/maven-external-upload-test/missing-commit-project/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..e83fa6959 --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/system-tests/maven-external-upload-test/missing-commit-project/mvnw b/system-tests/maven-external-upload-test/missing-commit-project/mvnw new file mode 100755 index 000000000..5643201c7 --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/system-tests/maven-external-upload-test/missing-commit-project/mvnw.cmd b/system-tests/maven-external-upload-test/missing-commit-project/mvnw.cmd new file mode 100644 index 000000000..8a15b7f31 --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/system-tests/maven-external-upload-test/missing-commit-project/pom.xml b/system-tests/maven-external-upload-test/missing-commit-project/pom.xml new file mode 100644 index 000000000..b04911945 --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/pom.xml @@ -0,0 +1,82 @@ + + + + + 4.0.0 + org.example + missing-commit-project + 1.0-SNAPSHOT + missing-commit-project + + + UTF-8 + 1.8 + 1.8 + ${env.TEAMSCALE_PORT} + ${env.AGENT_VERSION} + + + + + org.junit.jupiter + junit-jupiter-engine + 5.8.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + org.jacoco + jacoco-maven-plugin + 0.8.9 + + + default-prepare-agent + + prepare-agent + + + + default-report + + report + + + + XML + + + + + + + com.teamscale + teamscale-maven-plugin + ${tia.agent.version} + + + + upload-coverage + + + + + http://localhost:${tia.teamscale.fake.port} + m + build + 6lJKEvNHeTxGPhMAi4D84DWqzoSFL1p4 + My Custom Unit Tests Partition + + + + + + diff --git a/system-tests/maven-external-upload-test/missing-commit-project/src/main/java/org/example/SUTF.java b/system-tests/maven-external-upload-test/missing-commit-project/src/main/java/org/example/SUTF.java new file mode 100644 index 000000000..af1d55e71 --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/src/main/java/org/example/SUTF.java @@ -0,0 +1,16 @@ +package org.example; + +/** + * Hello world! + * + */ +public class SUTF +{ + public void bla() { + System.out.println("bla"); + } + + public void foo() { + System.out.println("foo"); + } +} \ No newline at end of file diff --git a/system-tests/maven-external-upload-test/missing-commit-project/src/test/java/org/example/UnitTest.java b/system-tests/maven-external-upload-test/missing-commit-project/src/test/java/org/example/UnitTest.java new file mode 100644 index 000000000..55cee8d6e --- /dev/null +++ b/system-tests/maven-external-upload-test/missing-commit-project/src/test/java/org/example/UnitTest.java @@ -0,0 +1,17 @@ +package bar; + +import org.junit.jupiter.api.Test; +import org.example.SUTF; + +public class UnitTest { + + @Test + public void utBla() { + new SUTF().bla(); + } + + @Test + public void utFoo() { + new SUTF().foo(); + } +} \ No newline at end of file diff --git a/system-tests/maven-external-upload-test/src/test/java/com/teamscale/upload/MavenExternalUploadSystemTest.java b/system-tests/maven-external-upload-test/src/test/java/com/teamscale/upload/MavenExternalUploadSystemTest.java index 0389c85d1..c2e5d50d6 100644 --- a/system-tests/maven-external-upload-test/src/test/java/com/teamscale/upload/MavenExternalUploadSystemTest.java +++ b/system-tests/maven-external-upload-test/src/test/java/com/teamscale/upload/MavenExternalUploadSystemTest.java @@ -4,13 +4,16 @@ import com.teamscale.test.commons.SystemTestUtils; import com.teamscale.test.commons.TeamscaleMockServer; import org.apache.commons.lang3.SystemUtils; +import org.conqat.lib.commons.filesystem.FileSystemUtils; import org.conqat.lib.commons.io.ProcessUtils; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.nio.file.Paths; import static org.assertj.core.api.Assertions.assertThat; @@ -75,6 +78,23 @@ public void testIncorrectJaCoCoConfiguration() throws IOException { FAILING_MAVEN_PROJECT_NAME, "org.jacoco:jacoco-maven-plugin")); } + /** + * When no commit is given and no git repo is available, which is the usual fallback, a helpful error message should + * be shown (TS-40425). + */ + @Test + public void testErrorMessageOnMissingCommit(@TempDir Path tmpDir) throws IOException { + FileSystemUtils.copyFiles(new File("missing-commit-project"), tmpDir.toFile(), file -> true); + tmpDir.resolve("mvnw").toFile().setExecutable(true); + String projectPath = tmpDir.toAbsolutePath().toString(); + SystemTestUtils.runMavenTests(projectPath); + ProcessUtils.ExecutionResult result = runCoverageUploadGoal(projectPath); + assertThat(result).isNotNull(); + assertThat(result.getReturnCode()).isNotEqualTo(0); + assertThat(teamscaleMockServer.uploadedReports).isEmpty(); + assertThat(result.getStdout()).contains("There is no or configured in the pom.xml and it was not possible to determine the current revision"); + } + @AfterAll public static void stopFakeTeamscaleServer() { teamscaleMockServer.shutdown(); diff --git a/system-tests/teamscale-profiler-configuration-test/src/test/java/com/teamscale/client/TeamscaleProfilerConfigurationSystemTest.java b/system-tests/teamscale-profiler-configuration-test/src/test/java/com/teamscale/client/TeamscaleProfilerConfigurationSystemTest.java index 87ed40b71..3d0f1b37b 100644 --- a/system-tests/teamscale-profiler-configuration-test/src/test/java/com/teamscale/client/TeamscaleProfilerConfigurationSystemTest.java +++ b/system-tests/teamscale-profiler-configuration-test/src/test/java/com/teamscale/client/TeamscaleProfilerConfigurationSystemTest.java @@ -5,6 +5,8 @@ import org.conqat.lib.commons.io.ProcessUtils; import org.junit.jupiter.api.Test; +import java.util.LinkedHashSet; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -36,9 +38,11 @@ public void systemTestRetrieveConfig() throws Exception { teamscaleMockServer.shutdown(); - assertThat(teamscaleMockServer.getProfilerEvents()).as("We expect a sequence of interactions with the mock. " + + assertThat(new LinkedHashSet<>(teamscaleMockServer.getProfilerEvents())).as("We expect a sequence of interactions with the mock. " + "Note that unexpected interactions can be caused by old agent instances that have not been killed properly.") // - .containsExactly("Profiler registered and requested configuration my-config", "Profiler 123 sent heartbeat", + .containsExactly("Profiler registered and requested configuration my-config", + "Profiler 123 sent logs", + "Profiler 123 sent heartbeat", "Profiler 123 unregistered"); } diff --git a/system-tests/tia-client-test/src/main/java/testframework/CustomTestFramework.java b/system-tests/tia-client-test/src/main/java/testframework/CustomTestFramework.java index 84c6b7a6a..7e26ddcfe 100644 --- a/system-tests/tia-client-test/src/main/java/testframework/CustomTestFramework.java +++ b/system-tests/tia-client-test/src/main/java/testframework/CustomTestFramework.java @@ -44,7 +44,7 @@ public void runTestsWithTia() throws AgentHttpRequestFailedException { allTests.keySet().stream().map(name -> new ClusteredTestDetails(name, name, null, null, null)) .collect(toList())); - for (PrioritizableTestCluster cluster : testRun.getPrioritizedClusters()) { + for (PrioritizableTestCluster cluster : testRun.prioritizedClusters) { for (PrioritizableTest test : cluster.tests) { Runnable runnable = allTests.get(test.testName); RunningTest runningTest = testRun.startTest(test.testName); diff --git a/system-tests/tia-maven/build.gradle.kts b/system-tests/tia-maven/build.gradle.kts index 43532996d..60c105c0d 100644 --- a/system-tests/tia-maven/build.gradle.kts +++ b/system-tests/tia-maven/build.gradle.kts @@ -4,8 +4,6 @@ plugins { } tasks.test { - environment("AGENT_VERSION", version) - environment("TEAMSCALE_PORT", teamscalePort) // install dependencies needed by the Maven test project dependsOn(rootProject.tasks["publishToMavenLocal"]) } diff --git a/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenMultipleJobsTest.java b/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenMultipleJobsTest.java index 07f9f88f5..bc98d2d5a 100644 --- a/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenMultipleJobsTest.java +++ b/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenMultipleJobsTest.java @@ -2,7 +2,6 @@ import com.teamscale.test.commons.SystemTestUtils; import org.conqat.lib.commons.filesystem.FileSystemUtils; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import java.nio.file.Path; @@ -13,7 +12,6 @@ /** * Test class to check if multiple maven plugins can be started with dynamic port allocation. */ -@Disabled("https://cqse.atlassian.net/browse/TS-38429") public class TiaMavenMultipleJobsTest { /** diff --git a/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenSystemTest.java b/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenSystemTest.java index 337df1d59..78b55976d 100644 --- a/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenSystemTest.java +++ b/system-tests/tia-maven/src/test/java/com/teamscale/tia/TiaMavenSystemTest.java @@ -1,18 +1,17 @@ package com.teamscale.tia; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; - -import java.io.IOException; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - import com.teamscale.report.testwise.model.ETestExecutionResult; import com.teamscale.report.testwise.model.TestwiseCoverageReport; import com.teamscale.test.commons.SystemTestUtils; import com.teamscale.test.commons.TeamscaleMockServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; /** * Runs several Maven projects' Surefire tests that have the agent attached and one of our JUnit run listeners enabled. @@ -78,10 +77,10 @@ public void testMavenTia() throws Exception { public void testPreferBranchAndTimestampOverRevisionWhenProvidedManually() throws IOException { SystemTestUtils.runMavenTests("maven-project", "-DteamscaleRevision=abcd1337", "-DteamscaleTimestamp=master:HEAD"); - assertThat(teamscaleMockServer.impactedTestCommits.get(0)).matches("null, master:HEAD"); - assertThat(teamscaleMockServer.impactedTestCommits.get(1)).matches("null, master:HEAD"); - assertThat(teamscaleMockServer.uploadCommits.get(0)).matches("null, master:HEAD"); - assertThat(teamscaleMockServer.uploadCommits.get(1)).matches("null, master:HEAD"); + assertThat(teamscaleMockServer.impactedTestCommits.get(0)).matches("abcd1337, null"); + assertThat(teamscaleMockServer.impactedTestCommits.get(1)).matches("abcd1337, null"); + assertThat(teamscaleMockServer.uploadCommits.get(0)).matches("abcd1337, null"); + assertThat(teamscaleMockServer.uploadCommits.get(1)).matches("abcd1337, null"); } @Test diff --git a/teamscale-client/build.gradle.kts b/teamscale-client/build.gradle.kts index f0e2da8e7..263410236 100644 --- a/teamscale-client/build.gradle.kts +++ b/teamscale-client/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + kotlin("jvm") `java-library` com.teamscale.`java-convention` com.teamscale.coverage diff --git a/teamscale-client/src/main/java/com/teamscale/client/ClusteredTestDetails.java b/teamscale-client/src/main/java/com/teamscale/client/ClusteredTestDetails.java deleted file mode 100644 index 13c7c6687..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ClusteredTestDetails.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * {@link TestDetails} with additional information about which cluster of tests the test case belongs to during - * prioritization. - */ -public class ClusteredTestDetails extends TestDetails { - - /** - * A unique identifier for the cluster this test should be prioritized within. If null the test gets assigned its - * own unique cluster. - */ - public String clusterId; - - /** - * The partition for the cluster this test should be prioritized within and the result will be uploaded to. - */ - public String partition; - - @JsonCreator - public ClusteredTestDetails(@JsonProperty("uniformPath") String uniformPath, - @JsonProperty("sourcePath") String sourcePath, @JsonProperty("content") String content, - @JsonProperty("clusterId") String clusterId, - @JsonProperty("partition") String partition) { - super(uniformPath, sourcePath, content); - this.clusterId = clusterId; - this.partition = partition; - } - - /** - * Creates clustered test details with the given additional {@link TestData}. - *

- * Use this to easily mark additional files or data as belonging to that test case. Whenever the given - * {@link TestData} changes, this test will be selected to be run by the TIA. - *

- * Example: For a test that reads test data from an XML file, you should pass the contents of that XML file as its - * test data. Then, whenever the XML is modified, the corresponding test will be run by the TIA. - */ - public static ClusteredTestDetails createWithTestData(String uniformPath, String sourcePath, TestData testData, - String clusterId, String partition) { - return new ClusteredTestDetails(uniformPath, sourcePath, testData.hash, clusterId, partition); - } - -} - diff --git a/teamscale-client/src/main/java/com/teamscale/client/CommitDescriptor.java b/teamscale-client/src/main/java/com/teamscale/client/CommitDescriptor.java deleted file mode 100644 index 08a5e4ed0..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/CommitDescriptor.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.teamscale.client; - -import java.io.Serializable; -import java.util.Objects; - -/** Holds the branch and timestamp of a commit. */ -public class CommitDescriptor implements Serializable { - - /** Branch name of the commit. */ - public final String branchName; - - /** - * Timestamp of the commit. The timestamp is a string here because be also want to be able to handle HEAD and - * 123456p1. - */ - public final String timestamp; - - /** Constructor. */ - public CommitDescriptor(String branchName, String timestamp) { - this.branchName = branchName; - this.timestamp = timestamp; - } - - /** Constructor. */ - public CommitDescriptor(String branchName, long timestamp) { - this(branchName, String.valueOf(timestamp)); - } - - /** Parses the given commit descriptor string. */ - public static CommitDescriptor parse(String commit) { - if (commit.contains(":")) { - String[] split = commit.split(":"); - return new CommitDescriptor(split[0], split[1]); - } else { - return new CommitDescriptor("master", commit); - } - } - - /** Returns a string representation of the commit in a Teamscale REST API compatible format. */ - @Override - public String toString() { - return branchName + ":" + timestamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - CommitDescriptor that = (CommitDescriptor) o; - return Objects.equals(branchName, that.branchName) && - Objects.equals(timestamp, that.timestamp); - } - - @Override - public int hashCode() { - return Objects.hash(branchName, timestamp); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/FileLoggingInterceptor.java b/teamscale-client/src/main/java/com/teamscale/client/FileLoggingInterceptor.java deleted file mode 100644 index 351f489a1..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/FileLoggingInterceptor.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.teamscale.client; - -import okhttp3.Interceptor; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okio.Buffer; - -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.io.PrintWriter; - -/** - * OkHttpInterceptor which prints out the full request and server response of requests to a file. - */ -public class FileLoggingInterceptor implements Interceptor { - - private final File logfile; - - /** Constructor. */ - public FileLoggingInterceptor(File logfile) { - this.logfile = logfile; - } - - @Override - public Response intercept(Chain chain) throws IOException { - Request request = chain.request(); - - long requestStartTime = System.nanoTime(); - try (PrintWriter fileWriter = new PrintWriter(new FileWriter(logfile))) { - fileWriter.write(String.format("--> Sending request %s on %s %s%n%s%n", request.method(), request.url(), - chain.connection(), - request.headers())); - - Buffer requestBuffer = new Buffer(); - if (request.body() != null) { - request.body().writeTo(requestBuffer); - } - fileWriter.write(requestBuffer.readUtf8()); - - Response response = getResponse(chain, request, fileWriter); - - long requestEndTime = System.nanoTime(); - fileWriter.write(String - .format("<-- Received response for %s %s in %.1fms%n%s%n%n", response.code(), - response.request().url(), (requestEndTime - requestStartTime) / 1e6d, response.headers())); - - ResponseBody wrappedBody = null; - if (response.body() != null) { - MediaType contentType = response.body().contentType(); - String content = response.body().string(); - fileWriter.write(content); - - wrappedBody = ResponseBody.create(contentType, content); - } - return response.newBuilder().body(wrappedBody).build(); - } - } - - private Response getResponse(Chain chain, Request request, PrintWriter fileWriter) throws IOException { - try { - return chain.proceed(request); - } catch (Exception e) { - fileWriter.write("\n\nRequest failed!\n"); - e.printStackTrace(fileWriter); - throw e; - } - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/FileSystemUtils.java b/teamscale-client/src/main/java/com/teamscale/client/FileSystemUtils.java deleted file mode 100644 index f9748f5d1..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/FileSystemUtils.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.teamscale.client; - -import java.io.File; -import java.io.FileFilter; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; - -/** - * File system utilities. - */ -public class FileSystemUtils { - - /** Encoding for UTF-8. */ - public static final String UTF8_ENCODING = StandardCharsets.UTF_8.name(); - - /** Unix file path separator */ - private static final char UNIX_SEPARATOR = '/'; - - /** - * Checks if a directory exists. If not it creates the directory and all necessary parent directories. - * - * @throws IOException if directories couldn't be created. - */ - public static void ensureDirectoryExists(File directory) throws IOException { - if (!directory.exists() && !directory.mkdirs()) { - throw new IOException("Couldn't create directory: " + directory); - } - } - - /** - * Returns a list of all files and directories contained in the given directory and all subdirectories matching the - * filter provided. The given directory itself is not included in the result. - *

- * The file filter may or may not exclude directories. - *

- * This method knows nothing about (symbolic and hard) links, so care should be taken when traversing directories - * containing recursive links. - * - * @param directory the directory to start the search from. If this is null or the directory does not exist, an - * empty list is returned. - * @param filter the filter used to determine whether the result should be included. If the filter is null, all - * files and directories are included. - * @return the list of files found (the order is determined by the file system). - */ - public static List listFilesRecursively(File directory, FileFilter filter) { - if (directory == null || !directory.isDirectory()) { - return Collections.emptyList(); - } - List result = new ArrayList<>(); - listFilesRecursively(directory, result, filter); - return result; - } - - /** - * Returns the extension of the file. - * - * @return File extension, i.e. "java" for "FileSystemUtils.java", or - * null, if the file has no extension (i.e. if a filename - * contains no '.'), returns the empty string if the '.' is the filename's last character. - */ - public static String getFileExtension(File file) { - String name = file.getName(); - int posLastDot = name.lastIndexOf('.'); - if (posLastDot < 0) { - return null; - } - return name.substring(posLastDot + 1); - } - - /** - * Finds all files and directories contained in the given directory and all subdirectories matching the filter - * provided and put them into the result collection. The given directory itself is not included in the result. - *

- * This method knows nothing about (symbolic and hard) links, so care should be taken when traversing directories - * containing recursive links. - * - * @param directory the directory to start the search from. - * @param result the collection to add to all files found. - * @param filter the filter used to determine whether the result should be included. If the filter is null, all - * files and directories are included. - */ - private static void listFilesRecursively(File directory, Collection result, FileFilter filter) { - File[] files = directory.listFiles(); - if (files == null) { - // From the docs of `listFiles`: - // "If this abstract pathname does not denote a directory, then this method returns null." - // Based on this, it seems to be ok to just return here without throwing an exception. - return; - } - - for (File file : files) { - if (file.isDirectory()) { - listFilesRecursively(file, result, filter); - } - if (filter == null || filter.accept(file)) { - result.add(file); - } - } - } - - /** - * Replace platform dependent separator char with forward slashes to create system-independent paths. - */ - public static String normalizeSeparators(String path) { - return path.replace(File.separatorChar, UNIX_SEPARATOR); - } - - /** - * Copy an input stream to an output stream. This does not close the - * streams. - * - * @param input - * input stream - * @param output - * output stream - * @return number of bytes copied - * @throws IOException - * if an IO exception occurs. - */ - public static int copy(InputStream input, OutputStream output) throws IOException { - byte[] buffer = new byte[1024]; - int size = 0; - int len; - while ((len = input.read(buffer)) > 0) { - output.write(buffer, 0, len); - size += len; - } - return size; - } - - /** - * Returns the name of the given file without extension. Example: - * '/home/joe/data.dat' returns 'data'. - */ - public static String getFilenameWithoutExtension(File file) { - return getFilenameWithoutExtension(file.getName()); - } - - /** - * Returns the name of the given file without extension. Example: 'data.dat' returns 'data'. - */ - public static String getFilenameWithoutExtension(String fileName) { - return StringUtils.removeLastPart(fileName, '.'); - } - -} \ No newline at end of file diff --git a/teamscale-client/src/main/java/com/teamscale/client/HttpUtils.java b/teamscale-client/src/main/java/com/teamscale/client/HttpUtils.java deleted file mode 100644 index fbe0f1a50..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/HttpUtils.java +++ /dev/null @@ -1,226 +0,0 @@ -package com.teamscale.client; - -import okhttp3.Authenticator; -import okhttp3.Credentials; -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.ResponseBody; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import retrofit2.Response; -import retrofit2.Retrofit; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSession; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Proxy; -import java.security.GeneralSecurityException; -import java.security.SecureRandom; -import java.security.cert.X509Certificate; -import java.time.Duration; -import java.util.Base64; -import java.util.function.Consumer; - -/** - * Utility functions to set up {@link Retrofit} and {@link OkHttpClient}. - */ -public class HttpUtils { - - private static final Logger LOGGER = LoggerFactory.getLogger(HttpUtils.class); - - /** - * Default read timeout in seconds. - */ - public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(60); - - /** - * Default write timeout in seconds. - */ - public static final Duration DEFAULT_WRITE_TIMEOUT = Duration.ofSeconds(60); - - /** - * HTTP header used for authenticating against a proxy server - */ - public static final String PROXY_AUTHORIZATION_HTTP_HEADER = "Proxy-Authorization"; - - /** Controls whether {@link OkHttpClient}s built with this class will validate SSL certificates. */ - private static boolean shouldValidateSsl = true; - - /** @see #shouldValidateSsl */ - public static void setShouldValidateSsl(boolean shouldValidateSsl) { - HttpUtils.shouldValidateSsl = shouldValidateSsl; - } - - /** - * Creates a new {@link Retrofit} with proper defaults. The instance and the corresponding {@link OkHttpClient} can - * be customized with the given action. Read and write timeouts are set according to the default values. - */ - public static Retrofit createRetrofit(Consumer retrofitBuilderAction, - Consumer okHttpBuilderAction) { - return createRetrofit(retrofitBuilderAction, okHttpBuilderAction, DEFAULT_READ_TIMEOUT, DEFAULT_WRITE_TIMEOUT); - } - - /** - * Creates a new {@link Retrofit} with proper defaults. The instance and the corresponding {@link OkHttpClient} can - * be customized with the given action. Timeouts for reading and writing can be customized. - */ - public static Retrofit createRetrofit(Consumer retrofitBuilderAction, - Consumer okHttpBuilderAction, Duration readTimeout, - Duration writeTimeout) { - OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder(); - setTimeouts(httpClientBuilder, readTimeout, writeTimeout); - setUpSslValidation(httpClientBuilder); - setUpProxyServer(httpClientBuilder); - okHttpBuilderAction.accept(httpClientBuilder); - - Retrofit.Builder builder = new Retrofit.Builder().client(httpClientBuilder.build()); - retrofitBuilderAction.accept(builder); - return builder.build(); - } - - /** - * Java and/or OkHttp do not pick up the http.proxy* and https.proxy* system properties reliably. We need to teach - * OkHttp to always pick them up. - *

- * Sources: https://memorynotfound.com/configure-http-proxy-settings-java/ - * & - * https://stackoverflow.com/a/35567936 - */ - private static void setUpProxyServer(OkHttpClient.Builder httpClientBuilder) { - boolean setHttpsProxyWasSuccessful = setUpProxyServerForProtocol(ProxySystemProperties.Protocol.HTTPS, - httpClientBuilder); - if (!setHttpsProxyWasSuccessful) { - setUpProxyServerForProtocol(ProxySystemProperties.Protocol.HTTP, httpClientBuilder); - } - } - - private static boolean setUpProxyServerForProtocol(ProxySystemProperties.Protocol protocol, - OkHttpClient.Builder httpClientBuilder) { - - ProxySystemProperties proxySystemProperties = new ProxySystemProperties(protocol); - String proxyHost = proxySystemProperties.getProxyHost(); - int proxyPort = proxySystemProperties.getProxyPort(); - String proxyUser = proxySystemProperties.getProxyUser(); - String proxyPassword = proxySystemProperties.getProxyPassword(); - - if (proxySystemProperties.proxyServerIsSet()) { - useProxyServer(httpClientBuilder, proxyHost, proxyPort); - - if (proxySystemProperties.proxyAuthIsSet()) { - useProxyAuthenticator(httpClientBuilder, proxyUser, proxyPassword); - } - - return true; - } - return false; - - } - - private static void useProxyServer(OkHttpClient.Builder httpClientBuilder, String proxyHost, int proxyPort) { - httpClientBuilder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort))); - } - - private static void useProxyAuthenticator(OkHttpClient.Builder httpClientBuilder, String user, String password) { - Authenticator proxyAuthenticator = (route, response) -> { - String credential = Credentials.basic(user, password); - return response.request().newBuilder() - .header(PROXY_AUTHORIZATION_HTTP_HEADER, credential) - .build(); - }; - httpClientBuilder.proxyAuthenticator(proxyAuthenticator); - } - - - /** - * Sets sensible defaults for the {@link OkHttpClient}. - */ - private static void setTimeouts(OkHttpClient.Builder builder, Duration readTimeout, Duration writeTimeout) { - builder.connectTimeout(Duration.ofSeconds(60)); - builder.readTimeout(readTimeout); - builder.writeTimeout(writeTimeout); - } - - /** - * Enables or disables SSL certificate validation for the {@link Retrofit} instance - */ - private static void setUpSslValidation(OkHttpClient.Builder builder) { - if (shouldValidateSsl) { - // this is the default behaviour of OkHttp, so we don't need to do anything - return; - } - - SSLSocketFactory sslSocketFactory; - try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new TrustManager[]{TrustAllCertificatesManager.INSTANCE}, new SecureRandom()); - sslSocketFactory = sslContext.getSocketFactory(); - } catch (GeneralSecurityException e) { - LOGGER.error("Could not disable SSL certificate validation. Leaving it enabled", e); - return; - } - - // this causes OkHttp to accept all certificates - builder.sslSocketFactory(sslSocketFactory, TrustAllCertificatesManager.INSTANCE); - // this causes it to ignore invalid host names in the certificates - builder.hostnameVerifier((String hostName, SSLSession session) -> true); - } - - /** - * A simple implementation of {@link X509TrustManager} that simple trusts every certificate. - */ - public static class TrustAllCertificatesManager implements X509TrustManager { - - /** Singleton instance. */ - /*package*/ static final TrustAllCertificatesManager INSTANCE = new TrustAllCertificatesManager(); - - /** Returns null. */ - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - - /** Does nothing. */ - @Override - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // Nothing to do - } - - /** Does nothing. */ - @Override - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // Nothing to do - } - - } - - /** - * Returns the error body of the given response or a replacement string in case it is null. - */ - public static String getErrorBodyStringSafe(Response response) throws IOException { - ResponseBody errorBody = response.errorBody(); - if (errorBody == null) { - return ""; - } - return errorBody.string(); - } - - /** - * Returns an interceptor, which adds a basic auth header to a request. - */ - public static Interceptor getBasicAuthInterceptor(String username, String password) { - String credentials = username + ":" + password; - String basic = "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes()); - - return chain -> { - Request newRequest = chain.request().newBuilder().header("Authorization", basic).build(); - return chain.proceed(newRequest); - }; - } - -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ITeamscaleService.java b/teamscale-client/src/main/java/com/teamscale/client/ITeamscaleService.java deleted file mode 100644 index 50f3ab861..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ITeamscaleService.java +++ /dev/null @@ -1,222 +0,0 @@ -package com.teamscale.client; - -import okhttp3.MultipartBody; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Response; -import retrofit2.Retrofit; -import retrofit2.http.Body; -import retrofit2.http.DELETE; -import retrofit2.http.GET; -import retrofit2.http.Multipart; -import retrofit2.http.POST; -import retrofit2.http.PUT; -import retrofit2.http.Part; -import retrofit2.http.Path; -import retrofit2.http.Query; - -import java.io.IOException; -import java.util.List; - -/** {@link Retrofit} API specification for Teamscale. */ -public interface ITeamscaleService { - - /** - * Report upload API. - * - * @param commit A branch and timestamp to upload the report to. Can be null if revision is specified. - * @param moveToLastCommit Whether to move the upload timestamp to right after the last commit - * @param revision This parameter allows to pass a revision instead of a timestamp. Can be null if a - * timestamp is given. - * @param partition The name of the logical partition to store the results into. All existing data in this - * partition will be invalidated. A partition typically corresponds to one analysis run, - * i.e. if there are two independent builds/runs, they must use different partitions. - * @apiNote How to Upload - * External Analysis Results to Teamscale for details. - */ - @Multipart - @POST("api/v5.9.0/projects/{projectAliasOrId}/external-analysis/session/auto-create/report") - Call uploadExternalReport( - @Path("projectAliasOrId") String projectAliasOrId, - @Query("format") String format, - @Query("t") CommitDescriptor commit, - @Query("revision") String revision, - @Query("repository") String repository, - @Query("movetolastcommit") Boolean moveToLastCommit, - @Query("partition") String partition, - @Query("message") String message, - @Part("report") RequestBody report - ); - - /** - * Report upload API with {@link EReportFormat}. - * - * @see #uploadExternalReport(String, String, CommitDescriptor, String, String, Boolean, String, String, - * RequestBody) - */ - default Call uploadExternalReport( - String projectName, - EReportFormat format, - CommitDescriptor commit, - String revision, - String repository, - Boolean moveToLastCommit, - String partition, - String message, - RequestBody report - ) { - return uploadExternalReport(projectName, format.name(), commit, revision, repository, moveToLastCommit, - partition, message, report); - } - - /** - * Report upload API for multiple reports at once. - * - * @see #uploadExternalReport(String, String, CommitDescriptor, String, String, Boolean, String, String, - * RequestBody) - */ - @Multipart - @POST("api/v5.9.0/projects/{projectName}/external-analysis/session/auto-create/report") - Call uploadExternalReports( - @Path("projectName") String projectName, - @Query("format") EReportFormat format, - @Query("t") CommitDescriptor commit, - @Query("revision") String revision, - @Query("repository") String repository, - @Query("movetolastcommit") boolean moveToLastCommit, - @Query("partition") String partition, - @Query("message") String message, - @Part List report - ); - - /** - * Report upload API for multiple reports at once. This is an overloaded version that takes a string as report - * format so that consumers can add support for new report formats without the requirement that the teamscale-client - * needs to be adjusted beforehand. - * - * @see #uploadExternalReport(String, String, CommitDescriptor, String, String, Boolean, String, String, - * RequestBody) - */ - @Multipart - @POST("api/v5.9.0/projects/{projectName}/external-analysis/session/auto-create/report") - Call uploadExternalReports( - @Path("projectName") String projectName, - @Query("format") String format, - @Query("t") CommitDescriptor commit, - @Query("revision") String revision, - @Query("repository") String repository, - @Query("movetolastcommit") boolean moveToLastCommit, - @Query("partition") String partition, - @Query("message") String message, - @Part List report - ); - - /** Retrieve clustered impacted tests based on the given available tests and baseline timestamp. */ - @PUT("api/v9.4.0/projects/{projectName}/impacted-tests") - Call> getImpactedTests( - @Path("projectName") String projectName, - @Query("baseline") String baseline, - @Query("baseline-revision") String baselineRevision, - @Query("end") CommitDescriptor end, - @Query("end-revision") String endRevision, - @Query("repository") String repository, - @Query("partitions") List partitions, - @Query("include-non-impacted") boolean includeNonImpacted, - @Query("include-failed-and-skipped") boolean includeFailedAndSkippedTests, - @Query("ensure-processed") boolean ensureProcessed, - @Query("include-added-tests") boolean includeAddedTests, - @Body List availableTests - ); - - /** Retrieve unclustered impacted tests based on all tests known to Teamscale and the given baseline timestamp. */ - @GET("api/v9.4.0/projects/{projectName}/impacted-tests") - Call> getImpactedTests( - @Path("projectName") String projectName, - @Query("baseline") String baseline, - @Query("baseline-revision") String baselineRevision, - @Query("end") CommitDescriptor end, - @Query("end-revision") String endRevision, - @Query("repository") String repository, - @Query("partitions") List partitions, - @Query("include-non-impacted") boolean includeNonImpacted, - @Query("include-failed-and-skipped") boolean includeFailedAndSkippedTests, - @Query("ensure-processed") boolean ensureProcessed, - @Query("include-added-tests") boolean includeAddedTests - ); - - /** Registers a profiler to Teamscale and returns the profiler configuration it should be started with. */ - @POST("api/v2024.7.0/profilers") - Call registerProfiler( - @Query("configuration-id") String configurationId, - @Body ProcessInformation processInformation - ); - - /** Updates the profiler infos and sets the profiler to still alive. */ - @PUT("api/v2024.7.0/profilers/{profilerId}") - Call sendHeartbeat( - @Path("profilerId") String profilerId, - @Body ProfilerInfo profilerInfo - ); - - /** Removes the profiler identified by given ID. */ - @DELETE("api/v2024.7.0/profilers/{profilerId}") - Call unregisterProfiler(@Path("profilerId") String profilerId); - - @POST("api/v2024.7.0/profilers/{profilerId}/logs") - Call postProfilerLog( - @Path("profilerId") String profilerId, - @Body List logEntries - ); - - /** - * Uploads the given report body to Teamscale as blocking call with movetolastcommit set to false. - * - * @return Returns the request body if successful, otherwise throws an IOException. - */ - default String uploadReport( - String projectName, - CommitDescriptor commit, - String revision, - String repository, - String partition, - EReportFormat reportFormat, - String message, - RequestBody report - ) throws IOException { - Boolean moveToLastCommit = false; - if (revision != null) { - // When uploading to a revision, we don't need commit adjustment. - commit = null; - moveToLastCommit = null; - } - - try { - Response response = uploadExternalReport( - projectName, - reportFormat, - commit, - revision, - repository, - moveToLastCommit, - partition, - message, - report - ).execute(); - - ResponseBody body = response.body(); - if (response.isSuccessful()) { - if (body == null) { - return ""; - } - return body.string(); - } - - String errorBody = HttpUtils.getErrorBodyStringSafe(response); - throw new IOException( - "Request failed with error code " + response.code() + ". Response body: " + errorBody); - } catch (IOException e) { - throw new IOException("Failed to upload report. " + e.getMessage(), e); - } - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/JsonUtils.java b/teamscale-client/src/main/java/com/teamscale/client/JsonUtils.java deleted file mode 100644 index d08a3c463..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/JsonUtils.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonAutoDetect; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; - -import java.io.File; -import java.io.IOException; -import java.util.List; - -/** - * Utility class for serializing and deserializing JSON using Jackson. - */ -public class JsonUtils { - - /** - * Jackson ObjectMapper that is used for serializing and deserializing JSON objects. The visibility settings of the - * OBJECT_MAPPER are configured to include all fields when serializing or deserializing objects, regardless of their - * visibility modifiers (public, private, etc.). - */ - public static final ObjectMapper OBJECT_MAPPER = JsonMapper.builder() - .visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) - .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .serializationInclusion(JsonInclude.Include.NON_NULL) - .build(); - - /** - * Creates a new instance of {@link JsonFactory} using the default {@link ObjectMapper}. - */ - public static JsonFactory createFactory() { - return new JsonFactory(OBJECT_MAPPER); - } - - /** - * Deserializes a JSON string into an object of the given class. - */ - public static T deserialize(String json, Class clazz) throws JsonProcessingException { - return OBJECT_MAPPER.readValue(json, clazz); - } - - /** - * Deserializes the contents of the given file into an object of the given class. - */ - public static T deserializeFile(File file, Class clazz) throws IOException { - return OBJECT_MAPPER.readValue(file, clazz); - } - - /** - * Deserializes a JSON string into a list of objects of the given class. - */ - public static List deserializeList(String json, Class elementClass) throws JsonProcessingException { - return OBJECT_MAPPER.readValue(json, - OBJECT_MAPPER.getTypeFactory().constructCollectionLikeType(List.class, elementClass)); - } - - /** - * Serializes an object into its JSON representation. - */ - public static String serialize(Object value) throws JsonProcessingException { - return OBJECT_MAPPER.writeValueAsString(value); - } - - /** - * Serializes an object to a file with pretty printing enabled. - */ - public static void serializeToFile(File file, T value) throws IOException { - OBJECT_MAPPER.writer().withDefaultPrettyPrinter().writeValue(file, value); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTest.java b/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTest.java deleted file mode 100644 index d634c50fc..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.StringJoiner; - -/** - * {@link TestDetails} with information about their partition as well as tracking data used during prioritization of - * tests. Two instances are considered equal if the test details are equals. - */ -public class PrioritizableTest { - - /** The uniform path without the "-test-execution" or "-execution-unit-" prefix */ - public String testName; - - /** The uniform path of the test including the "-test-execution" or "-execution-unit-" prefix. */ - public String uniformPath; - - /** The reason the test has been selected. */ - public String selectionReason; - - /** Partition of the test. */ - public String partition; - - /** - * Duration in ms. May be null if not set. This can happen when the uploaded testwise coverage data does not include - * duration information or for new tests that have not been executed yet. - */ - public Long durationInMs; - - /** - * The score determined by the TIA algorithm. The value is guaranteed to be positive. Higher values describe a - * higher probability of the test to detect potential bugs. The value can only express a relative importance - * compared to other scores of the same request. It makes no sense to compare the score against absolute values. - */ - @JsonProperty("currentScore") - public double score; - - /** - * Field for storing the tests rank. The rank is the 1-based index of the test in the prioritized list. - */ - public int rank; - - @JsonCreator - public PrioritizableTest(@JsonProperty("testName") String testName) { - this.testName = testName; - } - - @Override - public String toString() { - return new StringJoiner(", ", PrioritizableTest.class.getSimpleName() + "[", "]") - .add("testName='" + testName + "'") - .add("uniformPath='" + uniformPath + "'") - .add("selectionReason='" + selectionReason + "'") - .add("partition='" + partition + "'") - .add("durationInMs=" + durationInMs) - .add("score=" + score) - .add("rank=" + rank) - .toString(); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTestCluster.java b/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTestCluster.java deleted file mode 100644 index 61bb23f31..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/PrioritizableTestCluster.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; -import java.util.StringJoiner; - -/** - * A {@link PrioritizableTestCluster} represents an ordered {@link List} of {@link PrioritizableTest}s which should be - * executed together to avoid overhead. The order of the {@link PrioritizableTest}s is determined by the prioritization - * of the {@link PrioritizableTest}s w.r.t. to each other. - *

- * A {@link PrioritizableTestCluster} assumes that possibly resource intensive setup or teardown operations (e.g. a - * class containing a method annotated with {@code BeforeClass} in JUnit4 or {@code BeforeAll} in JUnit5) can be - * executed once for a {@link PrioritizableTestCluster} instead of executing them for each {@link PrioritizableTest}. - */ -public class PrioritizableTestCluster { - - /** - * The unique cluster id to which all {@link PrioritizableTest}s belong. - * - * @see ClusteredTestDetails#clusterId - */ - public String clusterId; - - /** - * The score determined by the TIA algorithm. The value is guaranteed to be positive. Higher values describe a - * higher probability of the test to detect potential bugs. The value can only express a relative importance - * compared to other scores of the same request. It makes no sense to compare the score against absolute values. - * The value is 0 if no availableTests are given. - */ - @JsonProperty("currentScore") - public double score; - - /** - * Field for storing the tests rank. The rank is the 1-based index of the test - * in the prioritized list. - */ - public int rank; - - /** The {@link PrioritizableTest}s in this cluster. */ - public List tests; - - @JsonCreator - public PrioritizableTestCluster(@JsonProperty("clusterId") String clusterId, @JsonProperty("tests") List tests) { - this.clusterId = clusterId; - this.tests = tests; - } - - @Override - public String toString() { - return new StringJoiner(", ", PrioritizableTestCluster.class.getSimpleName() + "[", "]") - .add("clusterId='" + clusterId + "'") - .add("score=" + score) - .add("rank=" + rank) - .add("tests=" + tests) - .toString(); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProcessInformation.java b/teamscale-client/src/main/java/com/teamscale/client/ProcessInformation.java deleted file mode 100644 index 078e742b7..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProcessInformation.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.teamscale.client; - -/** Information about the process and machine the profiler is attached to. */ -public class ProcessInformation { - - /** Hostname of the machine it is running on */ - public final String hostname; - - /** Profiled PID */ - public final String pid; - - /** The timestamp at which the process was started. */ - public final long startedAtTimestamp; - - public ProcessInformation(String hostname, String pid, long startedAtTimestamp) { - this.hostname = hostname; - this.pid = pid; - this.startedAtTimestamp = startedAtTimestamp; - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProfilerConfiguration.java b/teamscale-client/src/main/java/com/teamscale/client/ProfilerConfiguration.java deleted file mode 100644 index 222c10174..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProfilerConfiguration.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.teamscale.client; - -/** Configuration options for a profiler. */ -public class ProfilerConfiguration { - - /** The ID if this configuration. */ - public String configurationId; - - /** The options that should be applied to the profiler. */ - public String configurationOptions; -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProfilerInfo.java b/teamscale-client/src/main/java/com/teamscale/client/ProfilerInfo.java deleted file mode 100644 index badff0cd6..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProfilerInfo.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.teamscale.client; - -/** Information about the profiler including the process it is attached to as well as the configuration it is running with. */ -public class ProfilerInfo { - - /** Information about the machine and process the profiler is running on. */ - public ProcessInformation processInformation; - - /** Concrete config that the profiler is running with. */ - public ProfilerConfiguration profilerConfiguration; - - public ProfilerInfo(ProcessInformation processInformation, ProfilerConfiguration profilerConfiguration) { - this.processInformation = processInformation; - this.profilerConfiguration = profilerConfiguration; - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProfilerLogEntry.java b/teamscale-client/src/main/java/com/teamscale/client/ProfilerLogEntry.java deleted file mode 100644 index 7ef2e5665..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProfilerLogEntry.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.teamscale.client; - -public class ProfilerLogEntry { - - private final long timestamp; - - private final String message; - - private final String severity; - - public ProfilerLogEntry(long timestamp, String message, String severity) { - this.timestamp = timestamp; - this.message = message; - this.severity = severity; - } - -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProxySystemProperties.java b/teamscale-client/src/main/java/com/teamscale/client/ProxySystemProperties.java deleted file mode 100644 index 2a862112f..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProxySystemProperties.java +++ /dev/null @@ -1,172 +0,0 @@ -package com.teamscale.client; - -import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Reads and writes Java system properties values for - *

    - *
  • http.proxyHost
  • - *
  • http.proxyPort
  • - *
  • http.proxyUser
  • - *
  • http.proxyPassword
  • - *
- * or the corresponding HTTPS counterpart (starting with https instead of http). - * These values set the proxy server and credentials that should be used later to reach Teamscale. - */ -public class ProxySystemProperties { - - private static final Logger LOGGER = LoggerFactory.getLogger(ProxySystemProperties.class); - - private static final String PROXY_HOST_SYSTEM_PROPERTY = ".proxyHost"; - private static final String PROXY_PORT_SYSTEM_PROPERTY = ".proxyPort"; - private static final String PROXY_USER_SYSTEM_PROPERTY = ".proxyUser"; - private static final String PROXY_PASSWORD_SYSTEM_PROPERTY = ".proxyPassword"; - - private final Protocol protocol; - - /** - * Indicates, whether the {@link ProxySystemProperties} should return values for the http.proxy* system properties - * or the https.proxy* ones - */ - public enum Protocol { - HTTP, - HTTPS; - - @Override - public String toString() { - return name().toLowerCase(); - } - } - - /** - * @param protocol Indicates, whether the {@link ProxySystemProperties} should use values for the http.proxy* system - * properties or the https.proxy* ones - */ - public ProxySystemProperties(Protocol protocol) { - this.protocol = protocol; - } - - /** - * Checks whether proxyHost and proxyPort are set - */ - public boolean proxyServerIsSet() { - return !StringUtils.isEmpty(getProxyHost()) && getProxyPort() > 0; - } - - /** - * Checks whether proxyUser and proxyPassword are set - */ - public boolean proxyAuthIsSet() { - return !StringUtils.isEmpty(getProxyUser()) && !StringUtils.isEmpty(getProxyPassword()); - } - - /** - * Read the http(s).proxyHost system variable - */ - public String getProxyHost() { - return System.getProperty(getProxyHostSystemPropertyName()); - } - - /** - * Read the http(s).proxyPort system variable. - * Returns -1 if no or an invalid port was set. - */ - public int getProxyPort() { - return parsePort(System.getProperty(getProxyPortSystemPropertyName())); - } - - /** - * Set the http(s).proxyHost system variable - */ - public void setProxyHost(String proxyHost) { - System.setProperty(getProxyHostSystemPropertyName(), proxyHost); - } - - @NotNull - private String getProxyHostSystemPropertyName() { - return protocol + PROXY_HOST_SYSTEM_PROPERTY; - } - - /** - * Set the http(s).proxyPort system variable - */ - public void setProxyPort(int proxyPort) { - setProxyPort(proxyPort + ""); - } - - /** - * Set the http(s).proxyPort system variable - */ - public void setProxyPort(String proxyPort) { - System.setProperty(getProxyPortSystemPropertyName(), proxyPort); - } - - /** - * Removes the http(s).proxyPort system variable. - * For testing. - */ - /*package*/ void removeProxyPort() { - System.clearProperty(getProxyPortSystemPropertyName()); - } - - @NotNull - private String getProxyPortSystemPropertyName() { - return protocol + PROXY_PORT_SYSTEM_PROPERTY; - } - - /** - * Get the http(s).proxyUser system variable - */ - public String getProxyUser() { - return System.getProperty(getProxyUserSystemPropertyName()); - } - - /** - * Set the http(s).proxyUser system variable - */ - public void setProxyUser(String proxyUser) { - System.setProperty(getProxyUserSystemPropertyName(), proxyUser); - } - - @NotNull - private String getProxyUserSystemPropertyName() { - return protocol + PROXY_USER_SYSTEM_PROPERTY; - } - - /** - * Get the http(s).proxyPassword system variable - */ - public String getProxyPassword() { - return System.getProperty(getProxyPasswordSystemPropertyName()); - } - - - /** - * Set the http(s).proxyPassword system variable - */ - public void setProxyPassword(String proxyPassword) { - System.setProperty(getProxyPasswordSystemPropertyName(), proxyPassword); - } - - @NotNull - private String getProxyPasswordSystemPropertyName() { - return protocol + PROXY_PASSWORD_SYSTEM_PROPERTY; - } - - /** Parses the given port string. Returns -1 if the string is null or not a valid number. */ - private int parsePort(String portString) { - if (portString == null) { - return -1; - } - - try { - return Integer.parseInt(portString); - } catch (NumberFormatException e) { - LOGGER.warn("Could not parse proxy port \"" + portString + - "\" set via \"" + getProxyPortSystemPropertyName() + "\""); - return -1; - } - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/StringUtils.java b/teamscale-client/src/main/java/com/teamscale/client/StringUtils.java deleted file mode 100644 index 82facb6d7..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/StringUtils.java +++ /dev/null @@ -1,191 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright 2005-2011 The ConQAT Project | -| | -| Licensed under the Apache License, Version 2.0 (the "License"); | -| you may not use this file except in compliance with the License. | -| You may obtain a copy of the License at | -| | -| http://www.apache.org/licenses/LICENSE-2.0 | -| | -| Unless required by applicable law or agreed to in writing, software | -| distributed under the License is distributed on an "AS IS" BASIS, | -| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | -| See the License for the specific language governing permissions and | -| limitations under the License. | -+-------------------------------------------------------------------------*/ -package com.teamscale.client; - -import java.text.NumberFormat; -import java.util.Iterator; -import java.util.Map; - -/** - * A utility class providing some advanced string functionality. - */ -public class StringUtils { - - /** Line separator of the current platform. */ - public static final String LINE_SEPARATOR = System.getProperty("line.separator"); - - /** The empty string. */ - public static final String EMPTY_STRING = ""; - - /** - * Checks if a string is empty (after trimming). - * - * @param text the string to check. - * @return true if string is empty or null, - * false otherwise. - */ - public static boolean isEmpty(String text) { - if (text == null) { - return true; - } - return EMPTY_STRING.equals(text.trim()); - } - - /** - * Determine if the supplied {@link String} is blank (i.e., {@code null} or consisting only of whitespace - * characters). - * - * @param str the string to check; may be {@code null} - * @return {@code true} if the string is blank - */ - public static boolean isBlank(String str) { - return (str == null || str.trim().isEmpty()); - } - - - /** - * Returns the beginning of a String, cutting off the last part which is separated by the given character. - *

- * E.g., removeLastPart("org.conqat.lib.commons.string.StringUtils", '.') gives "org.conqat.lib.commons.string". - * - * @param string the String - * @param separator separation character - * @return the String without the last part, or the original string if the separation character is not found. - */ - public static String removeLastPart(String string, char separator) { - int idx = string.lastIndexOf(separator); - if (idx == -1) { - return string; - } - - return string.substring(0, idx); - } - - /** - * Remove prefix from a string. - * - * @param string the string - * @param prefix the prefix - * @return the string without the prefix or the original string if it does not start with the prefix. - */ - public static String stripPrefix(String string, String prefix) { - if (string.startsWith(prefix)) { - return string.substring(prefix.length()); - } - return string; - } - - /** - * Remove suffix from a string. - * - * @param string the string - * @param suffix the suffix - * @return the string without the suffix or the original string if it does not end with the suffix. - */ - public static String stripSuffix(String string, String suffix) { - if (string.endsWith(suffix)) { - return string.substring(0, string.length() - suffix.length()); - } - return string; - } - - /** - * Create string representation of a map. - */ - public static String toString(Map map) { - return toString(map, EMPTY_STRING); - } - - /** - * Create string representation of a map. - * - * @param map the map - * @param indent a line indent - */ - public static String toString(Map map, String indent) { - StringBuilder result = new StringBuilder(); - Iterator keyIterator = map.keySet().iterator(); - - while (keyIterator.hasNext()) { - result.append(indent); - Object key = keyIterator.next(); - result.append(key); - result.append(" = "); - result.append(map.get(key)); - if (keyIterator.hasNext()) { - result.append(LINE_SEPARATOR); - } - } - - return result.toString(); - } - - /** - * Format number with number formatter, if number formatter is - * null, this uses {@link String#valueOf(double)}. - */ - public static String format(double number, NumberFormat numberFormat) { - if (numberFormat == null) { - return String.valueOf(number); - } - return numberFormat.format(number); - } - - /** - * Calculates the edit distance (aka Levenshtein distance) for two strings, i.e. the number of insert, delete or - * replace operations required to transform one string into the other. The running time is O(n*m) and the space - * complexity is O(n+m), where n/m are the lengths of the strings. Note that due to the high running time, for long - * strings the Diff class should be used, that has a more efficient algorithm, but only for insert/delete (not - * replace operation). - *

- * Although this is a clean reimplementation, the basic algorithm is explained here: - * http://en.wikipedia.org/wiki/Levenshtein_distance# Iterative_with_two_matrix_rows - */ - public static int editDistance(String s, String t) { - char[] sChars = s.toCharArray(); - char[] tChars = t.toCharArray(); - int m = s.length(); - int n = t.length(); - - int[] distance = new int[m + 1]; - for (int i = 0; i <= m; ++i) { - distance[i] = i; - } - - int[] oldDistance = new int[m + 1]; - for (int j = 1; j <= n; ++j) { - - // swap distance and oldDistance - int[] tmp = oldDistance; - oldDistance = distance; - distance = tmp; - - distance[0] = j; - for (int i = 1; i <= m; ++i) { - int cost = 1 + Math.min(distance[i - 1], oldDistance[i]); - if (sChars[i - 1] == tChars[j - 1]) { - cost = Math.min(cost, oldDistance[i - 1]); - } else { - cost = Math.min(cost, 1 + oldDistance[i - 1]); - } - distance[i] = cost; - } - } - - return distance[m]; - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleClient.java b/teamscale-client/src/main/java/com/teamscale/client/TeamscaleClient.java deleted file mode 100644 index 94731d468..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleClient.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.teamscale.client; - -import static com.teamscale.client.ETestImpactOptions.ENSURE_PROCESSED; -import static com.teamscale.client.ETestImpactOptions.INCLUDE_ADDED_TESTS; -import static com.teamscale.client.ETestImpactOptions.INCLUDE_FAILED_AND_SKIPPED; -import static com.teamscale.client.ETestImpactOptions.INCLUDE_NON_IMPACTED; - -import java.io.File; -import java.io.IOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.EnumSet; -import java.util.List; -import java.util.stream.Collectors; - -import okhttp3.HttpUrl; -import okhttp3.MultipartBody; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Response; - -/** Helper class to interact with Teamscale. */ -public class TeamscaleClient { - - /** Teamscale service implementation. */ - public final ITeamscaleService service; - - /** The project ID within Teamscale. */ - private final String projectId; - - /** Constructor with parameters for read and write timeout in seconds. */ - public TeamscaleClient(String baseUrl, String user, String accessToken, String projectId, Duration readTimeout, - Duration writeTimeout) { - this.projectId = projectId; - service = TeamscaleServiceGenerator - .createService(ITeamscaleService.class, HttpUrl.parse(baseUrl), user, accessToken, readTimeout, - writeTimeout); - } - - /** Constructor. */ - public TeamscaleClient(String baseUrl, String user, String accessToken, String projectId) { - this.projectId = projectId; - service = TeamscaleServiceGenerator - .createService(ITeamscaleService.class, HttpUrl.parse(baseUrl), user, accessToken, - HttpUtils.DEFAULT_READ_TIMEOUT, HttpUtils.DEFAULT_WRITE_TIMEOUT); - } - - /** Constructor with parameters for read and write timeout in seconds and logfile. */ - public TeamscaleClient(String baseUrl, String user, String accessToken, String projectId, File logfile, - Duration readTimeout, Duration writeTimeout) { - this.projectId = projectId; - service = TeamscaleServiceGenerator - .createServiceWithRequestLogging(ITeamscaleService.class, HttpUrl.parse(baseUrl), user, accessToken, - logfile, readTimeout, writeTimeout); - } - - /** Constructor with logfile. */ - public TeamscaleClient(String baseUrl, String user, String accessToken, String projectId, File logfile) { - this.projectId = projectId; - service = TeamscaleServiceGenerator - .createServiceWithRequestLogging(ITeamscaleService.class, HttpUrl.parse(baseUrl), user, accessToken, - logfile, HttpUtils.DEFAULT_READ_TIMEOUT, HttpUtils.DEFAULT_WRITE_TIMEOUT); - } - - /** - * Tries to retrieve the impacted tests from Teamscale. This should be used in a CI environment, because it ensures - * that the given commit has been processed by Teamscale and also considers previous failing tests for - * re-execution. - * - * @param availableTests A list of tests that is locally available for execution. This allows TIA to consider newly - * added tests in addition to those that are already known and allows to filter e.g. if the - * user has already selected a subset of relevant tests. This can be null to - * indicate that only tests known to Teamscale should be suggested. - * @param baseline The baseline timestamp AFTER which changes should be considered. Changes that happened - * exactly at the baseline will be excluded. In case you want to retrieve impacted tests for a - * single commit with a known timestamp you can append a "p1" suffix to the - * timestamp to indicate that you are interested in the changes that happened after the parent - * of the given commit. - * @param baselineRevision Same as baseline but accepts a revision (e.g. git SHA1) instead of a branch and timestamp - * @param endCommit The last commit for which changes should be considered. - * @param endRevision Same as endCommit but accepts a revision (e.g. git SHA1) instead of a branch and timestamp - * @param repository The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. - * Null or empty will lead to a lookup in all repositories in the Teamscale project. - * @param partitions The partitions that should be considered for retrieving impacted tests. Can be - * null to indicate that tests from all partitions should be returned. - * @return A list of test clusters to execute. If availableTests is null, a single dummy cluster is returned with - * all prioritized tests. - */ - public Response> getImpactedTests( - List availableTests, - String baseline, - String baselineRevision, - CommitDescriptor endCommit, - String endRevision, - String repository, - List partitions, - boolean includeNonImpacted, - boolean includeAddedTests, boolean includeFailedAndSkipped) throws IOException { - List selectedOptions = new ArrayList<>(Collections.singletonList(ENSURE_PROCESSED)); - if (includeNonImpacted) { - selectedOptions.add(INCLUDE_NON_IMPACTED); - } - if (includeAddedTests) { - selectedOptions.add(INCLUDE_ADDED_TESTS); - } - if (includeFailedAndSkipped) { - selectedOptions.add(INCLUDE_FAILED_AND_SKIPPED); - } - return getImpactedTests(availableTests, baseline, baselineRevision, endCommit, endRevision, repository, partitions, - selectedOptions.toArray(new ETestImpactOptions[0])); - } - - /** - * Tries to retrieve the impacted tests from Teamscale. Use this method if you want to query time range based or you - * want to exclude failed and skipped tests from previous test runs. - * - * @param availableTests A list of tests that is locally available for execution. This allows TIA to consider newly - * added tests in addition to those that are already known and allows to filter e.g. if the - * user has already selected a subset of relevant tests. This can be null to - * indicate that only tests known to Teamscale should be suggested. - * @param baseline The baseline timestamp AFTER which changes should be considered. Changes that happened - * exactly at the baseline will be excluded. In case you want to retrieve impacted tests for a - * single commit with a known timestamp you can append a "p1" suffix to the - * timestamp to indicate that you are interested in the changes that happened after the parent - * of the given commit. - * @param baselineRevision Same as baseline but accepts a revision (e.g. git SHA1) instead of a branch and timestamp - * @param endCommit The last commit for which changes should be considered. - * @param endRevision Same as endCommit but accepts a revision (e.g. git SHA1) instead of a branch and timestamp - * @param repository The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. - * Null or empty will lead to a lookup in all repositories in the Teamscale project. - * @param partitions The partitions that should be considered for retrieving impacted tests. Can be - * null to indicate that tests from all partitions should be returned. - * @param options A list of options (See {@link ETestImpactOptions} for more details) - * @return A list of test clusters to execute. If availableTests is null, a single dummy cluster is returned with - * all prioritized tests. - */ - private Response> getImpactedTests( - List availableTests, - String baseline, - String baselineRevision, - CommitDescriptor endCommit, - String endRevision, - String repository, - List partitions, - ETestImpactOptions... options) throws IOException { - EnumSet testImpactOptions = EnumSet.copyOf(Arrays.asList(options)); - boolean includeNonImpacted = testImpactOptions.contains(INCLUDE_NON_IMPACTED); - boolean includeFailedAndSkipped = testImpactOptions.contains(INCLUDE_FAILED_AND_SKIPPED); - boolean ensureProcessed = testImpactOptions.contains(ENSURE_PROCESSED); - boolean includeAddedTests = testImpactOptions.contains(INCLUDE_ADDED_TESTS); - - if (availableTests == null) { - return wrapInCluster( - service.getImpactedTests(projectId, baseline, baselineRevision, endCommit, endRevision, repository, partitions, - includeNonImpacted, - includeFailedAndSkipped, - ensureProcessed, includeAddedTests) - .execute()); - } else { - return service - .getImpactedTests(projectId, baseline, baselineRevision, endCommit, endRevision, repository, partitions, - includeNonImpacted, - includeFailedAndSkipped, - ensureProcessed, includeAddedTests, availableTests.stream() - .map(TestWithClusterId::fromClusteredTestDetails).collect( - Collectors.toList())) - .execute(); - } - } - - private static Response> wrapInCluster( - Response> testListResponse) { - if (testListResponse.isSuccessful()) { - return Response.success( - Collections.singletonList(new PrioritizableTestCluster("dummy", testListResponse.body())), - testListResponse.raw()); - } else { - return Response.error(testListResponse.errorBody(), testListResponse.raw()); - } - } - - /** Uploads multiple reports to Teamscale in the given {@link EReportFormat}. */ - public void uploadReports(EReportFormat reportFormat, Collection reports, CommitDescriptor commitDescriptor, - String revision, String repository, - String partition, String message) throws IOException { - uploadReports(reportFormat.name(), reports, commitDescriptor, revision, repository, partition, message); - } - - /** Uploads multiple reports to Teamscale. */ - public void uploadReports(String reportFormat, Collection reports, CommitDescriptor commitDescriptor, - String revision, String repository, - String partition, String message) throws IOException { - List partList = reports.stream().map(file -> { - RequestBody requestBody = RequestBody.create(MultipartBody.FORM, file); - return MultipartBody.Part.createFormData("report", file.getName(), requestBody); - }).collect(Collectors.toList()); - - Response response = service - .uploadExternalReports(projectId, reportFormat, commitDescriptor, revision, repository, true, partition, message, - partList).execute(); - if (!response.isSuccessful()) { - throw new IOException("HTTP request failed: " + HttpUtils.getErrorBodyStringSafe(response)); - } - } - - /** Uploads one in-memory report to Teamscale. */ - public void uploadReport(EReportFormat reportFormat, String report, CommitDescriptor commitDescriptor, - String revision, String repository, String partition, String message) throws IOException { - RequestBody requestBody = RequestBody.create(MultipartBody.FORM, report); - service.uploadReport(projectId, commitDescriptor, revision, repository, partition, reportFormat, message, requestBody); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServer.java b/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServer.java deleted file mode 100644 index 6f55ed24e..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServer.java +++ /dev/null @@ -1,184 +0,0 @@ -package com.teamscale.client; - -import okhttp3.HttpUrl; - -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; - -/** Holds Teamscale server details. */ -public class TeamscaleServer { - - /** The URL of the Teamscale server. */ - public HttpUrl url; - - /** The project id within Teamscale. */ - public String project; - - /** The user name used to authenticate against Teamscale. */ - public String userName; - - /** The user's access token. */ - public String userAccessToken; - - /** The partition to upload reports to. */ - public String partition; - - /** - * The corresponding code commit to which the coverage belongs. If this is null, the Agent is supposed to - * auto-detect the commit from the profiled code. - */ - public CommitDescriptor commit; - - /** - * The corresponding code revision to which the coverage belongs. This is currently only supported for testwise - * mode. - */ - public String revision; - - /** - * The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. - * Null or empty will lead to a lookup in all repositories in the Teamscale project. - */ - public String repository; - - /** - * The configuration ID that was used to retrieve the profiler configuration. This is only set here to append it to - * the default upload message. - */ - public String configId; - - private String message = null; - - /** - * The commit message shown in the Teamscale UI for the coverage upload. If the message is null, auto-generates a - * sensible message. - */ - public String getMessage() { - if (message == null) { - return createDefaultMessage(); - } - return message; - } - - private String createDefaultMessage() { - // we do not include the IP address here as one host may have - // - multiple network interfaces - // - each with multiple IP addresses - // - in either IPv4 or IPv6 format - // - and it is unclear which of those is "the right one" or even just which is useful (e.g. loopback or virtual - // adapters are not useful and might even confuse readers) - String hostnamePart = "uploaded from "; - try { - hostnamePart += "hostname: " + InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - hostnamePart += "an unknown computer"; - } - - String revisionPart = ""; - if (revision != null) { - revisionPart = "\nfor revision: " + revision; - } - - String configIdPart = ""; - if (configId != null) { - configIdPart = "\nprofiler configuration ID: " + configId; - } - - return partition + " coverage uploaded at " + - DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\n\n" + - hostnamePart + revisionPart + configIdPart; - } - - public void setMessage(String message) { - this.message = message; - } - - /** Checks if all fields required for a single-project Teamscale upload are non-null. */ - public boolean isConfiguredForSingleProjectTeamscaleUpload() { - return isConfiguredForServerConnection() && partition != null && project != null; - } - - /** Checks if all fields required for a Teamscale upload are non-null, except the project which must be null. */ - public boolean isConfiguredForMultiProjectUpload() { - return isConfiguredForServerConnection() && partition != null && project == null; - } - - /** Checks if all required fields to access a Teamscale server are non-null. */ - public boolean isConfiguredForServerConnection() { - return url != null && - userName != null && - userAccessToken != null; - } - - /** Whether a URL, user and access token were provided. */ - public boolean canConnectToTeamscale() { - return url != null && userName != null && userAccessToken != null; - } - - /** Returns whether all fields are null. */ - public boolean hasAllFieldsNull() { - return url == null && - project == null && - userName == null && - userAccessToken == null && - partition == null && - commit == null && - revision == null; - } - - /** Returns whether either a commit or revision has been set. */ - public boolean hasCommitOrRevision() { - return commit != null || revision != null; - } - - /** Checks if another TeamscaleServer has the same project and revision/commit as this TeamscaleServer instance. */ - public boolean hasSameProjectAndCommit(TeamscaleServer other) { - if (!this.project.equals(other.project)) { - return false; - } - if (this.revision != null) { - return this.revision.equals(other.revision); - } - return this.commit.equals(other.commit); - } - - @Override - public String toString() { - String at; - if (revision != null) { - at = "revision " + revision; - if (repository != null) { - at += "in repository " + repository; - } - } else { - at = "commit " + commit; - } - return "Teamscale " + url + " as user " + userName + " for " + project + " to " + partition + " at " + at; - } - - /** Creates a copy of the {@link TeamscaleServer} configuration, but with the given project and commit set. */ - public TeamscaleServer withProjectAndCommit(String teamscaleProject, CommitDescriptor commitDescriptor) { - TeamscaleServer teamscaleServer = new TeamscaleServer(); - teamscaleServer.url = url; - teamscaleServer.userName = userName; - teamscaleServer.userAccessToken = userAccessToken; - teamscaleServer.partition = partition; - teamscaleServer.project = teamscaleProject; - teamscaleServer.commit = commitDescriptor; - return teamscaleServer; - } - - /** Creates a copy of the {@link TeamscaleServer} configuration, but with the given project and revision set. */ - public TeamscaleServer withProjectAndRevision(String teamscaleProject, String revision) { - TeamscaleServer teamscaleServer = new TeamscaleServer(); - teamscaleServer.url = url; - teamscaleServer.userName = userName; - teamscaleServer.userAccessToken = userAccessToken; - teamscaleServer.partition = partition; - teamscaleServer.project = teamscaleProject; - teamscaleServer.revision = revision; - return teamscaleServer; - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServiceGenerator.java b/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServiceGenerator.java deleted file mode 100644 index 4da346738..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleServiceGenerator.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.teamscale.client; - -import okhttp3.HttpUrl; -import okhttp3.Interceptor; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import org.jetbrains.annotations.NotNull; -import retrofit2.Retrofit; -import retrofit2.converter.jackson.JacksonConverterFactory; - -import java.io.File; -import java.io.IOException; -import java.time.Duration; - -/** Helper class for generating a teamscale compatible service. */ -public class TeamscaleServiceGenerator { - - /** Custom user agent of the requests, used to monitor API traffic. */ - public static final String USER_AGENT = "Teamscale JaCoCo Agent"; - - /** - * Generates a {@link Retrofit} instance for the given service, which uses basic auth to authenticate against the - * server and which sets the accept header to json. - */ - public static S createService(Class serviceClass, HttpUrl baseUrl, String username, String accessToken, - Duration readTimeout, Duration writeTimeout, Interceptor... interceptors) { - return createServiceWithRequestLogging(serviceClass, baseUrl, username, accessToken, null, readTimeout, - writeTimeout, - interceptors); - } - - /** - * Generates a {@link Retrofit} instance for the given service, which uses basic auth to authenticate against the - * server and which sets the accept-header to json. Logs requests and responses to the given logfile. - */ - public static S createServiceWithRequestLogging(Class serviceClass, HttpUrl baseUrl, String username, - String accessToken, File logfile, Duration readTimeout, - Duration writeTimeout, Interceptor... interceptors) { - Retrofit retrofit = HttpUtils.createRetrofit( - retrofitBuilder -> retrofitBuilder.baseUrl(baseUrl) - .addConverterFactory(JacksonConverterFactory.create(JsonUtils.OBJECT_MAPPER)), - okHttpBuilder -> { - addInterceptors(okHttpBuilder, interceptors) - .addInterceptor(HttpUtils.getBasicAuthInterceptor(username, accessToken)) - .addInterceptor(new AcceptJsonInterceptor()) - .addNetworkInterceptor(new CustomUserAgentInterceptor()); - if (logfile != null) { - okHttpBuilder.addInterceptor(new FileLoggingInterceptor(logfile)); - } - }, - readTimeout, writeTimeout - ); - return retrofit.create(serviceClass); - } - - private static OkHttpClient.Builder addInterceptors(OkHttpClient.Builder builder, Interceptor... interceptors) { - for (Interceptor interceptor : interceptors) { - builder.addInterceptor(interceptor); - } - return builder; - } - - - /** - * Sets an Accept: application/json header on all requests. - */ - private static class AcceptJsonInterceptor implements Interceptor { - - @NotNull - @Override - public Response intercept(Chain chain) throws IOException { - Request newRequest = chain.request().newBuilder().header("Accept", "application/json").build(); - return chain.proceed(newRequest); - } - } - - /** - * Sets the custom user agent {@link #USER_AGENT} header on all requests. - */ - public static class CustomUserAgentInterceptor implements Interceptor { - @NotNull - @Override - public Response intercept(Chain chain) throws IOException { - Request newRequest = chain.request().newBuilder().header("User-Agent", USER_AGENT).build(); - return chain.proceed(newRequest); - } - } - -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TestData.java b/teamscale-client/src/main/java/com/teamscale/client/TestData.java deleted file mode 100644 index bcdd73cd7..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TestData.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.teamscale.client; - -import org.apache.commons.codec.binary.Hex; -import org.apache.commons.codec.digest.DigestUtils; - -import java.io.IOException; -import java.nio.file.Path; -import java.security.MessageDigest; - -/** - * Represents additional test data to attach to {@link ClusteredTestDetails}. Use the {@link Builder} to create new - * {@link TestData} objects. - *

- * Internally, the data you pass to the builder is hashed and only the hash is transferred as {@link - * ClusteredTestDetails#content} to Teamscale to save network bandwidth and RAM. Whenever a test case's hash changes, - * Teamscale will select it for the next TIA test run. - */ -public class TestData { - - /** The hash of the test data which will be sent to Teamscale as the {@link ClusteredTestDetails#content}. */ - /*package*/ final String hash; - - private TestData(String hash) { - this.hash = hash; - } - - /** - * Builder for {@link TestData} objects. This class is thread-safe and ensures that reading the test data does not - * result in {@link OutOfMemoryError}s. - */ - public static class Builder { - - private static final byte[] DIGEST_SEPARATOR = "-!#!-".getBytes(); - - private MessageDigest digest = DigestUtils.getSha1Digest(); - - /** Adds the given bytes as additional test data. */ - public synchronized Builder addByteArray(byte[] content) { - ensureHasNotBeenFinalized(); - DigestUtils.updateDigest(digest, content); - DigestUtils.updateDigest(digest, DIGEST_SEPARATOR); - return this; - } - - private void ensureHasNotBeenFinalized() { - if (digest == null) { - throw new IllegalStateException( - "You tried to use this TestData.Builder after calling #build() on it. Builders cannot be reused."); - } - } - - /** Adds the given String as additional test data. */ - public synchronized Builder addString(String content) { - ensureHasNotBeenFinalized(); - DigestUtils.updateDigest(digest, content); - DigestUtils.updateDigest(digest, DIGEST_SEPARATOR); - return this; - } - - /** Adds the contents of the given file path as additional test data. */ - public synchronized Builder addFileContent(Path fileWithContent) throws IOException { - ensureHasNotBeenFinalized(); - DigestUtils.updateDigest(digest, fileWithContent); - DigestUtils.updateDigest(digest, DIGEST_SEPARATOR); - return this; - } - - /** - * Builds the {@link TestData} object. After calling this method, you cannot use this builder anymore. - */ - public synchronized TestData build() { - ensureHasNotBeenFinalized(); - String hash = Hex.encodeHexString(digest.digest()); - digest = null; - return new TestData(hash); - } - } - -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TestDetails.java b/teamscale-client/src/main/java/com/teamscale/client/TestDetails.java deleted file mode 100644 index 20e047bb0..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TestDetails.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.Objects; - -/** - * Contains details about a test. - */ -public class TestDetails { - - /** Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. */ - public String uniformPath; - - /** - * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a base - * class will have the sourcePath pointing to the base class which contains the actual implementation whereas - * uniformPath will contain the the class name of the most specific subclass, from where it was actually executed. - */ - public String sourcePath; - - /** - * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the - * specification or similar. You can include e.g. a hash of each test's test data so that whenever the test data - * changes, the corresponding test is re-run. - */ - public String content; - - @JsonCreator - public TestDetails(@JsonProperty("uniformPath") String uniformPath, @JsonProperty("sourcePath") String sourcePath, - @JsonProperty("content") String content) { - this.uniformPath = uniformPath; - this.sourcePath = sourcePath; - this.content = content; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - TestDetails that = (TestDetails) o; - return Objects.equals(uniformPath, that.uniformPath) && - Objects.equals(sourcePath, that.sourcePath) && - Objects.equals(content, that.content); - } - - @Override - public int hashCode() { - return Objects.hash(uniformPath, sourcePath, content); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/TestWithClusterId.java b/teamscale-client/src/main/java/com/teamscale/client/TestWithClusterId.java deleted file mode 100644 index 1e6d6d823..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TestWithClusterId.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.teamscale.client; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; - -/** - * Test with additional information about which cluster of tests the test case belongs to during prioritization. - */ -public class TestWithClusterId { - - - /** - * The uniform path of the test (unescaped and without -test-execution- prefix). - */ - public final String testName; - - /** - * The hashed content of the test. - */ - public final String hash; - - /** - * The partition of the test. - */ - public final String partition; - - /** - * A unique identifier for the cluster this test should be prioritized within. May not be null. - */ - public final String clusterId; - - @JsonCreator - public TestWithClusterId(@JsonProperty("testName") String testName, @JsonProperty("hash") String hash, - @JsonProperty("partition") String partition, @JsonProperty("clusterId") String clusterId) { - this.testName = testName; - this.hash = hash; - this.partition = partition; - this.clusterId = clusterId; - } - - /** - * Creates a #TestWithClusterId from a #ClusteredTestDetails object. - */ - public static TestWithClusterId fromClusteredTestDetails(ClusteredTestDetails clusteredTestDetails) { - return new TestWithClusterId(clusteredTestDetails.uniformPath, clusteredTestDetails.content, - clusteredTestDetails.partition, clusteredTestDetails.clusterId); - } -} diff --git a/teamscale-client/src/main/java/com/teamscale/client/AntPatternUtils.java b/teamscale-client/src/main/kotlin/com/teamscale/client/AntPatternUtils.kt similarity index 51% rename from teamscale-client/src/main/java/com/teamscale/client/AntPatternUtils.java rename to teamscale-client/src/main/kotlin/com/teamscale/client/AntPatternUtils.kt index 79d80fd7b..c38333363 100644 --- a/teamscale-client/src/main/java/com/teamscale/client/AntPatternUtils.java +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/AntPatternUtils.kt @@ -14,95 +14,98 @@ | See the License for the specific language governing permissions and | | limitations under the License. | +-------------------------------------------------------------------------*/ -package com.teamscale.client; +package com.teamscale.client -import java.util.regex.Pattern; -import java.util.regex.PatternSyntaxException; +import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException /** * Utility methods for dealing with Ant pattern as defined at http://ant.apache.org/manual/dirtasks.html#patterns - *

+ * + * * We implement a special version where a trailing '.' can be used to only match files without file extension (i.e. file * names without dot). */ -public class AntPatternUtils { - - /** Converts an ANT pattern to a regex pattern. */ - public static Pattern convertPattern(String antPattern, boolean caseSensitive) throws PatternSyntaxException { - - antPattern = normalizePattern(antPattern); +object AntPatternUtils { + /** Converts an ANT pattern to a regex pattern. */ + @Throws(PatternSyntaxException::class) + fun convertPattern(antPattern: String, caseSensitive: Boolean): Pattern { + var normalized = normalizePattern(antPattern) // ant specialty: trailing /** is optional // for example **/e*/** will also match foo/entry - boolean addTrailAll = false; - if (antPattern.endsWith("/**")) { - addTrailAll = true; - antPattern = StringUtils.stripSuffix(antPattern, "/**"); + var addTrailAll = false + if (normalized.endsWith("/**")) { + addTrailAll = true + normalized = StringUtils.stripSuffix(normalized, "/**") } - StringBuilder patternBuilder = new StringBuilder(); - convertPlainPattern(antPattern, patternBuilder); + val patternBuilder = StringBuilder() + convertPlainPattern(normalized, patternBuilder) if (addTrailAll) { // the tail pattern is optional (i.e. we do not require the '/'), // but the "**" is only in effect if the '/' occurs - patternBuilder.append("(/.*)?"); + patternBuilder.append("(/.*)?") } - return compileRegex(patternBuilder.toString(), antPattern, caseSensitive); + return compileRegex(patternBuilder.toString(), normalized, caseSensitive) } - /** Compiles the given regex. */ - private static Pattern compileRegex(String regex, String antPattern, boolean caseSensitive) { + /** Compiles the given regex. */ + private fun compileRegex(regex: String, antPattern: String, caseSensitive: Boolean): Pattern { try { - return Pattern.compile(regex, determineRegexFlags(caseSensitive)); - } catch (PatternSyntaxException e) { + return Pattern.compile(regex, determineRegexFlags(caseSensitive)) + } catch (e: PatternSyntaxException) { // make pattern syntax exception more understandable - throw new PatternSyntaxException( - "Error compiling ANT pattern '" + antPattern + "' to regular expression. " + e.getDescription(), - e.getPattern(), e.getIndex()); + throw PatternSyntaxException( + "Error compiling ANT pattern '" + antPattern + "' to regular expression. " + e.description, + e.pattern, e.index + ) } } - /** Returns the flags to be used for the regular expression. */ - private static int determineRegexFlags(boolean caseSensitive) { + /** Returns the flags to be used for the regular expression. */ + private fun determineRegexFlags(caseSensitive: Boolean): Int { // Use DOTALL flag, as on Unix the file names can contain line breaks - int flags = Pattern.DOTALL; + var flags = Pattern.DOTALL if (!caseSensitive) { - flags |= Pattern.CASE_INSENSITIVE; + flags = flags or Pattern.CASE_INSENSITIVE } - return flags; + return flags } /** - * Normalizes the given pattern by ensuring forward slashes and mapping trailing slash to '/**'. + * Normalizes the given pattern by ensuring forward slashes and mapping trailing slash to '/ **'. */ - private static String normalizePattern(String antPattern) { - antPattern = FileSystemUtils.normalizeSeparators(antPattern); + private fun normalizePattern(antPattern: String): String { + var normalized = FileSystemUtils.normalizeSeparators(antPattern) // ant pattern syntax: if a pattern ends with /, then ** is // appended - if (antPattern.endsWith("/")) { - antPattern += "**"; + if (normalized.endsWith("/")) { + normalized += "**" } - return antPattern; + return normalized } /** * Converts a plain ANT pattern to a regular expression, by replacing special characters, such as '?', '*', and - * '**'. The created pattern is appended to the given {@link StringBuilder}. The pattern must be plain, i.e. all ANT + * '**'. The created pattern is appended to the given [StringBuilder]. The pattern must be plain, i.e. all ANT * specialties, such as trailing double stars have to be dealt with beforehand. */ - private static void convertPlainPattern(String antPattern, StringBuilder patternBuilder) { - for (int i = 0; i < antPattern.length(); ++i) { - char c = antPattern.charAt(i); + private fun convertPlainPattern(antPattern: String, patternBuilder: StringBuilder) { + var i = 0 + while (i < antPattern.length) { + val c = antPattern[i] if (c == '?') { - patternBuilder.append("[^/]"); + patternBuilder.append("[^/]") } else if (c != '*') { - patternBuilder.append(Pattern.quote(Character.toString(c))); + patternBuilder.append(Pattern.quote(c.toString())) } else { - i = convertStarSequence(antPattern, patternBuilder, i); + i = convertStarSequence(antPattern, patternBuilder, i) } + ++i } } @@ -110,50 +113,48 @@ private static void convertPlainPattern(String antPattern, StringBuilder pattern * Converts a sequence of the ant pattern starting with a star at the given index. Appends the pattern fragment the * the builder and returns the index to continue scanning from. */ - private static int convertStarSequence(String antPattern, StringBuilder patternBuilder, int index) { - boolean doubleStar = isCharAt(antPattern, index + 1, '*'); + private fun convertStarSequence(antPattern: String, patternBuilder: StringBuilder, index: Int): Int { + val doubleStar = isCharAt(antPattern, index + 1, '*') if (doubleStar) { // if the double star is followed by a slash, the entire // group becomes optional, as we want "**/foo" to also // match a top-level "foo" - boolean doubleStarSlash = isCharAt(antPattern, index + 2, '/'); + val doubleStarSlash = isCharAt(antPattern, index + 2, '/') if (doubleStarSlash) { - patternBuilder.append("(.*/)?"); - return index + 2; + patternBuilder.append("(.*/)?") + return index + 2 } - boolean doubleStarDot = isCharAtBeforeSlashOrEnd(antPattern, index + 2, '.'); + val doubleStarDot = isCharAtBeforeSlashOrEnd(antPattern, index + 2, '.') if (doubleStarDot) { - patternBuilder.append("(.*/)?[^/.]*[.]?"); - return index + 2; + patternBuilder.append("(.*/)?[^/.]*[.]?") + return index + 2 } - patternBuilder.append(".*"); - return index + 1; + patternBuilder.append(".*") + return index + 1 } - boolean starDot = isCharAtBeforeSlashOrEnd(antPattern, index + 1, '.'); + val starDot = isCharAtBeforeSlashOrEnd(antPattern, index + 1, '.') if (starDot) { - patternBuilder.append("[^/.]*[.]?"); - return index + 1; + patternBuilder.append("[^/.]*[.]?") + return index + 1 } - patternBuilder.append("[^/]*"); - return index; + patternBuilder.append("[^/]*") + return index } /** * Returns whether the given position exists in the string and equals the given character, and the given character * is either at the end or right before a slash. */ - private static boolean isCharAtBeforeSlashOrEnd(String s, int position, char character) { - return isCharAt(s, position, character) && (position + 1 == s.length() || isCharAt(s, position + 1, '/')); - } + private fun isCharAtBeforeSlashOrEnd(s: String, position: Int, character: Char) = + isCharAt(s, position, character) && (position + 1 == s.length || isCharAt(s, position + 1, '/')) /** * Returns whether the given position exists in the string and equals the given character. */ - private static boolean isCharAt(String s, int position, char character) { - return position < s.length() && s.charAt(position) == character; - } + private fun isCharAt(s: String, position: Int, character: Char) = + position < s.length && s[position] == character } \ No newline at end of file diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ClusteredTestDetails.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ClusteredTestDetails.kt new file mode 100644 index 000000000..20680dc0f --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ClusteredTestDetails.kt @@ -0,0 +1,38 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * [TestDetails] with additional information about which cluster of tests the test case belongs to during + * prioritization. + */ +class ClusteredTestDetails @JsonCreator constructor( + /** + * The uniform path of the test case. + */ + @JsonProperty("uniformPath") uniformPath: String, + + /** + * The source path of the test case, if available. + */ + @JsonProperty("sourcePath") sourcePath: String?, + /** + * The content associated with the test case, if available. + */ + @JsonProperty("content") content: String?, + /** + * A unique identifier for the cluster this test should be prioritized within. If null the test gets assigned its + * own unique cluster. + */ + @param:JsonProperty( + "clusterId" + ) var clusterId: String?, + /** + * The partition for the cluster this test should be prioritized within and the result will be uploaded to. + */ + @param:JsonProperty( + "partition" + ) var partition: String? +) : TestDetails(uniformPath, sourcePath, content) + diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/CommitDescriptor.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/CommitDescriptor.kt new file mode 100644 index 000000000..cb470558b --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/CommitDescriptor.kt @@ -0,0 +1,36 @@ +package com.teamscale.client + +import java.io.Serializable + +/** Holds the branch and timestamp of a commit. */ +data class CommitDescriptor( + /** Branch name of the commit. */ + @JvmField val branchName: String, + /** + * Timestamp of the commit. The timestamp is a string here because be also want to be able to handle HEAD and + * 123456p1. + */ + @JvmField val timestamp: String +) : Serializable { + /** Constructor. */ + constructor(branchName: String, timestamp: Long) : this(branchName, timestamp.toString()) + + /** Returns a string representation of the commit in a Teamscale REST API compatible format. */ + override fun toString() = "$branchName:$timestamp" + + companion object { + /** Parses the given commit descriptor string. */ + @JvmStatic + fun parse(commit: String?): CommitDescriptor? { + if (commit == null) { + return null + } + if (commit.contains(":")) { + val split = commit.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + return CommitDescriptor(split[0], split[1]) + } else { + return CommitDescriptor("master", commit) + } + } + } +} diff --git a/teamscale-client/src/main/java/com/teamscale/client/EReportFormat.java b/teamscale-client/src/main/kotlin/com/teamscale/client/EReportFormat.kt similarity index 62% rename from teamscale-client/src/main/java/com/teamscale/client/EReportFormat.java rename to teamscale-client/src/main/kotlin/com/teamscale/client/EReportFormat.kt index 60b59612f..f52009a21 100644 --- a/teamscale-client/src/main/java/com/teamscale/client/EReportFormat.java +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/EReportFormat.kt @@ -1,61 +1,63 @@ -package com.teamscale.client; +package com.teamscale.client /** * Enum of report formats. * This is the subset of the report formats supported by Teamscale that the plugin currently implements support for. * See https://docs.teamscale.com/reference/upload-formats-and-samples/#supported-formats-for-upload */ -public enum EReportFormat { - - /** Astree xml report format. */ +enum class EReportFormat( + /** Each ReportFormat needs a readable name for the UI */ + val readableName: String +) { + /** Astree xml report format. */ ASTREE("Astree"), - /** JaCoCo (Java Code Coverage) xml report format. */ + /** JaCoCo (Java Code Coverage) xml report format. */ JACOCO("JaCoCo"), - /** Cobertura (Java test coverage) xml report format. */ + /** Cobertura (Java test coverage) xml report format. */ COBERTURA("Cobertura"), - /** Gcov (Profiling tool for code compiled with gcc) report format. */ + /** Gcov (Profiling tool for code compiled with gcc) report format. */ GCOV("Gcov"), - /** Lcov (Linux Test Project (LTP) front-end for Gcov) report format. */ + /** Lcov (Linux Test Project (LTP) front-end for Gcov) report format. */ LCOV("Lcov"), - /** Ctc (Testwell CTC++ coverage for C/C++) report format. */ + /** Ctc (Testwell CTC++ coverage for C/C++) report format. */ CTC("Testwell CTC++"), - /** XR.Baboon (code coverage for C# on Mono) report format. */ + /** XR.Baboon (code coverage for C# on Mono) report format. */ XR_BABOON("XR.Baboon"), - /** MS Coverage report format (CQSE Coverage Merger). */ + /** MS Coverage report format (CQSE Coverage Merger). */ MS_COVERAGE("MS Coverage"), - /** MS Coverage report format (Visual Studio Coverage Merger). */ + /** MS Coverage report format (Visual Studio Coverage Merger). */ VS_COVERAGE("VS Coverage"), - /** dotCover (Jetbrains coverage tool for .NET) report format. */ + /** dotCover (Jetbrains coverage tool for .NET) report format. */ DOT_COVER("dotCover"), - /** Roslyn (Microsoft .NET) report format. */ + /** Roslyn (Microsoft .NET) report format. */ ROSLYN("Roslyn"), - /** Golang coverage report format @see https://golang.org/cmd/cover/ */ + /** Golang coverage report format @see https://golang.org/cmd/cover/ */ GOLANG_COVERAGE("Go Cover"), - /** Simple coverage report format for testing. */ + /** Simple coverage report format for testing. */ SIMPLE("Teamscale Simple Coverage"), - /** Cppcheck (static analysis for C/C++) results in XML format. */ + /** Cppcheck (static analysis for C/C++) results in XML format. */ CPPCHECK("Cppcheck"), - /** PClint/FlexeLint (C/C++) coverage report format. */ + /** PClint/FlexeLint (C/C++) coverage report format. */ PCLINT("PClint/FlexeLint"), - /** Clang (C, C++, Objective C/C++) findings report format. */ + /** Clang (C, C++, Objective C/C++) findings report format. */ CLANG("Clang"), - /** Pylint (static analysis for Python) findings report format. */ + /** Pylint (static analysis for Python) findings report format. */ PYLINT("Pylint"), /** @@ -74,53 +76,53 @@ public enum EReportFormat { */ FINDBUGS("FindBugs/SpotBugs"), - /** Bullseye (C++) coverage report format. */ + /** Bullseye (C++) coverage report format. */ BULLSEYE("Bullseye"), - /** FxCop (.NET) findings report format. */ + /** FxCop (.NET) findings report format. */ FXCOP("FxCop"), - /** SpCop (Sharepoint Code Analysis) findings report format. */ + /** SpCop (Sharepoint Code Analysis) findings report format. */ SPCOP("SpCop"), - /** JUnit (Java unit tests) report format. */ + /** JUnit (Java unit tests) report format. */ JUNIT("JUnit"), - /** XUnit (.NET unit tests) report format. */ + /** XUnit (.NET unit tests) report format. */ XUNIT("XUnit"), - /** MS Test report format. */ + /** MS Test report format. */ MS_TEST("MSTest"), - /** Istanbul (JavaScript coverage) report format. */ + /** Istanbul (JavaScript coverage) report format. */ ISTANBUL("Istanbul"), - /** C# Compiler warnings format */ + /** C# Compiler warnings format */ CS_COMPILER_WARNING("C# Compiler Warning"), - /** Simulink Model Advisor report format. */ + /** Simulink Model Advisor report format. */ MODEL_ADVISOR("Simulink Model Advisor"), - /** CSV issues report format */ + /** CSV issues report format */ ISSUE_CSV("CSV Issues"), - /** CSV spec items report format */ + /** CSV spec items report format */ REQUIREMENTS_CSV("CSV Spec Items"), - /** Our own export format for SAP code inspector findings. */ + /** Our own export format for SAP code inspector findings. */ SAP_CODE_INSPECTOR("SAP Code Inspector Export"), - /** Custom testwise coverage report format. */ + /** Custom testwise coverage report format. */ TESTWISE_COVERAGE("Testwise Coverage"), - /** Line coverage data in txt format from Xcode (xccov). */ + /** Line coverage data in txt format from Xcode (xccov). */ XCODE("Xcode Coverage"), - /** Clover test coverage */ + /** Clover test coverage */ CLOVER("Clover"), - /** OpenCover test coverage */ + /** OpenCover test coverage */ OPEN_COVER("OpenCover"), /** @@ -128,16 +130,16 @@ public enum EReportFormat { */ IEC_COVERAGE("IEC Coverage"), - /** LLVM coverage report format. */ + /** LLVM coverage report format. */ LLVM("LLVM Coverage"), - /** Our own generic finding format. */ + /** Our own generic finding format. */ GENERIC_FINDINGS("Teamscale Generic Findings"), - /** Our own generic non-code metric format. */ + /** Our own generic non-code metric format. */ GENERIC_NON_CODE("Teamscale Non-Code Metrics"), - /** Parasoft C/C++text. */ + /** Parasoft C/C++text. */ PARASOFT_CPP_TEST("Parasoft C/C++test"), /** @@ -145,19 +147,19 @@ public enum EReportFormat { * compilers (e.g., clang) and contain included paths and initial defines. * * @see "https://sarcasm.github.io/notes/dev/compilation-database.html" + * * @see "http://clang.llvm.org/docs/JSONCompilationDatabase.html" */ COMPILATION_DATABASE("JSON Compilation Database"), - /** Mypy (static type checker for Python) findings report format. */ + /** Mypy (static type checker for Python) findings report format. */ MYPY("Mypy"), /** * Coverage report generated with the Lauterbach Trace32 tool. See section for - * Supported - * Upload Formats and Samples in the user guide for more information about - * the Lauterbach Trace32 tool. See the {@code trace32_example_reports.zip} for + * [Supported + * Upload Formats and Samples](https://docs.teamscale.com/reference/upload-formats-and-samples/) in the user guide for more information about + * the Lauterbach Trace32 tool. See the `trace32_example_reports.zip` for * additional report examples. */ LAUTERBACH_TRACE32("Lauterbach Trace32"), @@ -165,17 +167,5 @@ public enum EReportFormat { /** * jQAssistant report format. */ - JQASSISTANT("jQAssistant"); - - /** Each ReportFormat needs a readable name for the UI */ - private final String readableName; - - EReportFormat(String readableName) { - this.readableName = readableName; - } - - public String getReadableName() { - return this.readableName; - } - + JQASSISTANT("jQAssistant") } diff --git a/teamscale-client/src/main/java/com/teamscale/client/ETestImpactOptions.java b/teamscale-client/src/main/kotlin/com/teamscale/client/ETestImpactOptions.kt similarity index 93% rename from teamscale-client/src/main/java/com/teamscale/client/ETestImpactOptions.java rename to teamscale-client/src/main/kotlin/com/teamscale/client/ETestImpactOptions.kt index 506df2ec9..47d276920 100644 --- a/teamscale-client/src/main/java/com/teamscale/client/ETestImpactOptions.java +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ETestImpactOptions.kt @@ -1,8 +1,7 @@ -package com.teamscale.client; - -/** Described all feature toggles of the impacted-tests services. */ -public enum ETestImpactOptions { +package com.teamscale.client +/** Described all feature toggles of the impacted-tests services. */ +enum class ETestImpactOptions { /** * Returns impacted tests first and then appends all non-impacted tests. This always returns all tests, but still * allows to fail faster as impacted tests are executed first. diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/FileLoggingInterceptor.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/FileLoggingInterceptor.kt new file mode 100644 index 000000000..27bd7fc02 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/FileLoggingInterceptor.kt @@ -0,0 +1,61 @@ +package com.teamscale.client + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import java.io.File +import java.io.FileWriter +import java.io.IOException +import java.io.PrintWriter + +/** + * [okhttp3.Interceptor] which prints out the full request and server response of requests to a file. + */ +class FileLoggingInterceptor( + private val logfile: File +) : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val requestStartTime = System.nanoTime() + PrintWriter(FileWriter(logfile)).use { fileWriter -> + fileWriter.write( + "--> Sending request ${request.method} on ${request.url} ${chain.connection()}\n${request.headers}\n" + ) + val requestBuffer = Buffer() + request.body?.writeTo(requestBuffer) + fileWriter.write(requestBuffer.readUtf8()) + + val response = getResponse(chain, request, fileWriter) + val requestEndTime = System.nanoTime() + fileWriter.write( + "<-- Received response for ${response.code} ${response.request.url} in ${(requestEndTime - requestStartTime) / 1e6}ms\n${response.headers}\n\n" + ) + + var wrappedBody: ResponseBody? = null + response.body?.let { + val contentType = it.contentType() + val content = it.string() + fileWriter.write(content) + + wrappedBody = content.toResponseBody(contentType) + } + return response.newBuilder().body(wrappedBody).build() + } + } + + @Throws(IOException::class) + private fun getResponse(chain: Interceptor.Chain, request: Request, fileWriter: PrintWriter): Response { + try { + return chain.proceed(request) + } catch (e: Exception) { + fileWriter.write("\n\nRequest failed!\n") + e.printStackTrace(fileWriter) + throw e + } + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt new file mode 100644 index 000000000..10d48702b --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/FileSystemUtils.kt @@ -0,0 +1,116 @@ +package com.teamscale.client + +import java.io.* + +/** + * File system utilities. + */ +object FileSystemUtils { + /** Unix file path separator */ + private const val UNIX_SEPARATOR = '/' + + /** + * Returns a list of all files and directories contained in the given directory and all subdirectories matching the + * filter provided. The given directory itself is not included in the result. + * + * + * The file filter may or may not exclude directories. + * + * + * This method knows nothing about (symbolic and hard) links, so care should be taken when traversing directories + * containing recursive links. + * + * @param directory the directory to start the search from. If this is null or the directory does not exist, an + * empty list is returned. + * @param filter the filter used to determine whether the result should be included. If the filter is null, all + * files and directories are included. + * @return the list of files found (the order is determined by the file system). + */ + @JvmStatic + fun listFilesRecursively(directory: File?, filter: FileFilter?): List { + if (directory == null || !directory.isDirectory) { + return emptyList() + } + val result = arrayListOf() + listFilesRecursively(directory, result, filter) + return result + } + + /** + * Returns the extension of the file. + * + * @return File extension, i.e. "java" for "FileSystemUtils.java", or + * `null`, if the file has no extension (i.e. if a filename + * contains no '.'), returns the empty string if the '.' is the filename's last character. + */ + @JvmStatic + fun getFileExtension(file: File): String? { + val name = file.name + val posLastDot = name.lastIndexOf('.') + if (posLastDot < 0) { + return null + } + return name.substring(posLastDot + 1) + } + + /** + * Finds all files and directories contained in the given directory and all subdirectories matching the filter + * provided and put them into the result collection. The given directory itself is not included in the result. + * + * + * This method knows nothing about (symbolic and hard) links, so care should be taken when traversing directories + * containing recursive links. + * + * @param directory the directory to start the search from. + * @param result the collection to add to all files found. + * @param filter the filter used to determine whether the result should be included. If the filter is null, all + * files and directories are included. + */ + private fun listFilesRecursively(directory: File, result: MutableCollection, filter: FileFilter?) { + val files = directory.listFiles() + ?: // From the docs of `listFiles`: + // "If this abstract pathname does not denote a directory, then this method returns null." + // Based on this, it seems to be ok to just return here without throwing an exception. + return + + for (file in files) { + if (file.isDirectory) { + listFilesRecursively(file, result, filter) + } + if (filter == null || filter.accept(file)) { + result.add(file) + } + } + } + + /** + * Replace platform dependent separator char with forward slashes to create system-independent paths. + */ + @JvmStatic + fun normalizeSeparators(path: String) = + path.replace(File.separatorChar, UNIX_SEPARATOR) + + /** + * Copy an input stream to an output stream. This does *not* close the + * streams. + * + * @param input + * input stream + * @param output + * output stream + * @return number of bytes copied + * @throws IOException + * if an IO exception occurs. + */ + @Throws(IOException::class) + fun copy(input: InputStream, output: OutputStream): Int { + val buffer = ByteArray(1024) + var size = 0 + var len: Int + while ((input.read(buffer).also { len = it }) > 0) { + output.write(buffer, 0, len) + size += len + } + return size + } +} \ No newline at end of file diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt new file mode 100644 index 000000000..2b27429d3 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/HttpUtils.kt @@ -0,0 +1,213 @@ +package com.teamscale.client + +import okhttp3.Authenticator +import okhttp3.Credentials.basic +import okhttp3.Interceptor +import okhttp3.OkHttpClient.Builder +import org.slf4j.LoggerFactory +import retrofit2.Retrofit +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Proxy +import java.security.GeneralSecurityException +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.time.Duration +import java.util.* +import java.util.function.Consumer +import javax.net.ssl.* + +/** + * Utility functions to set up [Retrofit] and [okhttp3.OkHttpClient]. + */ +object HttpUtils { + private val LOGGER = LoggerFactory.getLogger(HttpUtils::class.java) + + /** + * Default read timeout in seconds. + */ + @JvmField + val DEFAULT_READ_TIMEOUT: Duration = Duration.ofSeconds(60) + + /** + * Default write timeout in seconds. + */ + @JvmField + val DEFAULT_WRITE_TIMEOUT: Duration = Duration.ofSeconds(60) + + /** + * HTTP header used for authenticating against a proxy server + */ + const val PROXY_AUTHORIZATION_HTTP_HEADER = "Proxy-Authorization" + + /** Controls whether [okhttp3.OkHttpClient]s built with this class will validate SSL certificates. */ + private var shouldValidateSsl = true + + /** @see .shouldValidateSsl + */ + @JvmStatic + fun setShouldValidateSsl(shouldValidateSsl: Boolean) { + HttpUtils.shouldValidateSsl = shouldValidateSsl + } + + /** + * Creates a new [Retrofit] with proper defaults. The instance and the corresponding [okhttp3.OkHttpClient] can + * be customized with the given action. Timeouts for reading and writing can be customized. + */ + /** + * Creates a new [Retrofit] with proper defaults. The instance and the corresponding [okhttp3.OkHttpClient] can + * be customized with the given action. Read and write timeouts are set according to the default values. + */ + @JvmOverloads + @JvmStatic + fun createRetrofit( + retrofitBuilderAction: Consumer, + okHttpBuilderAction: Consumer, readTimeout: Duration = DEFAULT_READ_TIMEOUT, + writeTimeout: Duration = DEFAULT_WRITE_TIMEOUT + ): Retrofit { + val httpClientBuilder = Builder().apply { + setTimeouts(readTimeout, writeTimeout) + setUpSslValidation() + setUpProxyServer() + } + okHttpBuilderAction.accept(httpClientBuilder) + + val builder = Retrofit.Builder().client(httpClientBuilder.build()) + retrofitBuilderAction.accept(builder) + return builder.build() + } + + /** + * Java and/or OkHttp do not pick up the http.proxy* and https.proxy* system properties reliably. We need to teach + * OkHttp to always pick them up. + * + * + * Sources: [https://memorynotfound.com/configure-http-proxy-settings-java/](https://memorynotfound.com/configure-http-proxy-settings-java/) + * & + * [https://stackoverflow.com/a/35567936](https://stackoverflow.com/a/35567936) + */ + private fun Builder.setUpProxyServer() { + val setHttpsProxyWasSuccessful = setUpProxyServerForProtocol( + ProxySystemProperties.Protocol.HTTPS, + this + ) + if (!setHttpsProxyWasSuccessful) { + setUpProxyServerForProtocol(ProxySystemProperties.Protocol.HTTP, this) + } + } + + private fun setUpProxyServerForProtocol( + protocol: ProxySystemProperties.Protocol, + httpClientBuilder: Builder + ): Boolean { + val proxySystemProperties = TeamscaleProxySystemProperties(protocol) + try { + if (!proxySystemProperties.isProxyServerSet()) { + return false + } + + val host = proxySystemProperties.proxyHost ?: return false + useProxyServer(httpClientBuilder, host, proxySystemProperties.proxyPort) + } catch (e: ProxySystemProperties.IncorrectPortFormatException) { + LOGGER.warn(e.message) + return false + } + + if (proxySystemProperties.isProxyAuthSet()) { + val user = proxySystemProperties.proxyUser ?: return false + val password = proxySystemProperties.proxyPassword ?: return false + useProxyAuthenticator(httpClientBuilder, user, password) + } + + return true + } + + private fun useProxyServer(httpClientBuilder: Builder, proxyHost: String, proxyPort: Int) { + httpClientBuilder.proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress(proxyHost, proxyPort))) + } + + private fun useProxyAuthenticator(httpClientBuilder: Builder, user: String, password: String) { + val proxyAuthenticator = Authenticator { _, response -> + response.request.newBuilder() + .header(PROXY_AUTHORIZATION_HTTP_HEADER, basic(user, password)) + .build() + } + httpClientBuilder.proxyAuthenticator(proxyAuthenticator) + } + + /** + * Sets sensible defaults for the [okhttp3.OkHttpClient]. + */ + private fun Builder.setTimeouts(readTimeout: Duration, writeTimeout: Duration) { + connectTimeout(Duration.ofSeconds(60)) + readTimeout(readTimeout) + writeTimeout(writeTimeout) + } + + /** + * Enables or disables SSL certificate validation for the [Retrofit] instance + */ + private fun Builder.setUpSslValidation() { + if (shouldValidateSsl) { + // this is the default behaviour of OkHttp, so we don't need to do anything + return + } + + val sslSocketFactory: SSLSocketFactory + try { + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(TrustAllCertificatesManager), SecureRandom()) + sslSocketFactory = sslContext.socketFactory + } catch (e: GeneralSecurityException) { + LOGGER.error("Could not disable SSL certificate validation. Leaving it enabled", e) + return + } + + // this causes OkHttp to accept all certificates + sslSocketFactory(sslSocketFactory, TrustAllCertificatesManager) + // this causes it to ignore invalid host names in the certificates + hostnameVerifier { _, _ -> true } + } + + /** + * Returns the error body of the given response or a replacement string in case it is null. + */ + @Throws(IOException::class) + @JvmStatic + fun getErrorBodyStringSafe(response: retrofit2.Response): String { + val errorBody = response.errorBody() ?: return "" + return errorBody.string() + } + + /** + * Returns an interceptor, which adds a basic auth header to a request. + */ + @JvmStatic + fun getBasicAuthInterceptor(username: String, password: String): Interceptor { + val credentials = "$username:$password" + val basic = "Basic " + Base64.getEncoder().encodeToString(credentials.toByteArray()) + + return Interceptor { chain -> + val newRequest = chain.request().newBuilder().header("Authorization", basic).build() + chain.proceed(newRequest) + } + } + + /** + * A simple implementation of [X509TrustManager] that simple trusts every certificate. + */ + object TrustAllCertificatesManager : X509TrustManager { + /** Returns `null`. */ + override fun getAcceptedIssuers() = arrayOf() + + /** Does nothing. */ + override fun checkServerTrusted(certs: Array, authType: String) { + // Nothing to do + } + + /** Does nothing. */ + override fun checkClientTrusted(certs: Array, authType: String) { + // Nothing to do + } + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt new file mode 100644 index 000000000..a383026ab --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt @@ -0,0 +1,199 @@ +package com.teamscale.client + +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.http.* +import java.io.IOException + +/** [Retrofit] API specification for Teamscale. */ +interface ITeamscaleService { + + /** + * Report upload API. + * + * @param commit A branch and timestamp to upload the report to. Can be null if revision is specified. + * @param moveToLastCommit Whether to move the upload timestamp to right after the last commit + * @param revision This parameter allows passing a revision instead of a timestamp. Can be null if a + * timestamp is given. + * @param partition The name of the logical partition to store the results into. All existing data in this + * partition will be invalidated. A partition typically corresponds to one analysis run, + * i.e., if there are two independent builds/runs, they must use different partitions. + * @apiNote [How to Upload External Analysis Results to Teamscale](https://docs.teamscale.com/howto/uploading-external-results/#upload-via-command-line) + * for details. + */ + @Multipart + @POST("api/v5.9.0/projects/{projectAliasOrId}/external-analysis/session/auto-create/report") + fun uploadExternalReport( + @Path("projectAliasOrId") projectAliasOrId: String, + @Query("format") format: String, + @Query("t") commit: CommitDescriptor?, + @Query("revision") revision: String?, + @Query("repository") repository: String?, + @Query("movetolastcommit") moveToLastCommit: Boolean?, + @Query("partition") partition: String, + @Query("message") message: String, + @Part("report") report: RequestBody + ): Call + + /** + * Report upload API for multiple reports at once. + * + * @see uploadExternalReport + */ + @Multipart + @POST("api/v5.9.0/projects/{projectName}/external-analysis/session/auto-create/report") + fun uploadExternalReports( + @Path("projectName") projectName: String, + @Query("format") format: EReportFormat, + @Query("t") commit: CommitDescriptor?, + @Query("revision") revision: String?, + @Query("repository") repository: String?, + @Query("movetolastcommit") moveToLastCommit: Boolean, + @Query("partition") partition: String, + @Query("message") message: String, + @Part report: List + ): Call + + /** + * Report upload API for multiple reports at once. This is an overloaded version that takes a string as report + * format so that consumers can add support for new report formats without requiring changes to teamscale-client. + * + * @see uploadExternalReport + */ + @Multipart + @POST("api/v5.9.0/projects/{projectName}/external-analysis/session/auto-create/report") + fun uploadExternalReports( + @Path("projectName") projectName: String, + @Query("format") format: String, + @Query("t") commit: CommitDescriptor?, + @Query("revision") revision: String?, + @Query("repository") repository: String?, + @Query("movetolastcommit") moveToLastCommit: Boolean, + @Query("partition") partition: String, + @Query("message") message: String, + @Part report: List + ): Call + + /** Retrieve clustered impacted tests based on the given available tests and baseline timestamp. */ + @PUT("api/v9.4.0/projects/{projectName}/impacted-tests") + fun getImpactedTests( + @Path("projectName") projectName: String, + @Query("baseline") baseline: String?, + @Query("baseline-revision") baselineRevision: String?, + @Query("end") end: CommitDescriptor?, + @Query("end-revision") endRevision: String?, + @Query("repository") repository: String?, + @Query("partitions") partitions: List, + @Query("include-non-impacted") includeNonImpacted: Boolean, + @Query("include-failed-and-skipped") includeFailedAndSkippedTests: Boolean, + @Query("ensure-processed") ensureProcessed: Boolean, + @Query("include-added-tests") includeAddedTests: Boolean, + @Body availableTests: List + ): Call> + + /** Retrieve unclustered impacted tests based on all tests known to Teamscale and the given baseline timestamp. */ + @GET("api/v9.4.0/projects/{projectName}/impacted-tests") + fun getImpactedTests( + @Path("projectName") projectName: String, + @Query("baseline") baseline: String?, + @Query("baseline-revision") baselineRevision: String?, + @Query("end") end: CommitDescriptor?, + @Query("end-revision") endRevision: String?, + @Query("repository") repository: String?, + @Query("partitions") partitions: List, + @Query("include-non-impacted") includeNonImpacted: Boolean, + @Query("include-failed-and-skipped") includeFailedAndSkippedTests: Boolean, + @Query("ensure-processed") ensureProcessed: Boolean, + @Query("include-added-tests") includeAddedTests: Boolean + ): Call> + + /** Registers a profiler to Teamscale and returns the profiler configuration it should be started with. */ + @Deprecated("This is here for compatibility with older Teamscale version; remove after these are no longer supported.") + @POST("api/v9.4.0/running-profilers") + fun registerProfilerLegacy( + @Query("configuration-id") configurationId: String?, + @Body processInformation: ProcessInformation? + ): Call + + /** Updates the profiler infos and sets the profiler to still alive. */ + @Deprecated("This is here for compatibility with older Teamscale version; remove after these are no longer supported.") + @PUT("api/v9.4.0/running-profilers/{profilerId}") + fun sendHeartbeatLegacy( + @Path("profilerId") profilerId: String?, + @Body profilerInfo: ProfilerInfo? + ): Call + + /** Removes the profiler identified by given ID. */ + @Deprecated("This is here for compatibility with older Teamscale version; remove after these are no longer supported.") + @DELETE("api/v9.4.0/running-profilers/{profilerId}") + fun unregisterProfilerLegacy(@Path("profilerId") profilerId: String?): Call + + /** Registers a profiler to Teamscale and returns the profiler configuration it should be started with. */ + @POST("api/v2024.7.0/profilers") + fun registerProfiler( + @Query("configuration-id") configurationId: String?, + @Body processInformation: ProcessInformation? + ): Call + + /** Updates the profiler infos and sets the profiler to still alive. */ + @PUT("api/v2024.7.0/profilers/{profilerId}") + fun sendHeartbeat( + @Path("profilerId") profilerId: String, + @Body profilerInfo: ProfilerInfo? + ): Call + + /** Removes the profiler identified by given ID. */ + @DELETE("api/v2024.7.0/profilers/{profilerId}") + fun unregisterProfiler(@Path("profilerId") profilerId: String): Call + + /** Send logs to Teamscale */ + @POST("api/v2024.7.0/profilers/{profilerId}/logs") + fun postProfilerLog( + @Path("profilerId") profilerId: String, + @Body logEntries: List? + ): Call +} + +/** + * Uploads the given report body to Teamscale as blocking call with movetolastcommit set to false. + * + * @return Returns the request body if successful, otherwise throws an IOException. + */ +@Throws(IOException::class) +fun ITeamscaleService.uploadReport( + projectName: String, + commit: CommitDescriptor?, + revision: String?, + repository: String?, + partition: String, + reportFormat: EReportFormat, + message: String, + report: RequestBody +): String { + var commitNull = commit + var moveToLastCommit: Boolean? = false + if (revision != null) { + // When uploading to a revision, we don't need commit adjustment. + commitNull = null + moveToLastCommit = null + } + + try { + val response = uploadExternalReport( + projectName, reportFormat.name, commitNull, revision, repository, moveToLastCommit, partition, message, report + ).execute() + + val body = response.body() + if (response.isSuccessful) { + return body?.string() ?: "" + } + + val errorBody = HttpUtils.getErrorBodyStringSafe(response) + throw IOException("Request failed with error code ${response.code()}. Response body: $errorBody") + } catch (e: IOException) { + throw IOException("Failed to upload report. ${e.message}", e) + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/JsonUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/JsonUtils.kt new file mode 100644 index 000000000..67a269f4e --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/JsonUtils.kt @@ -0,0 +1,75 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.json.JsonMapper +import java.io.File +import java.io.IOException + +/** + * Utility class for serializing and deserializing JSON using Jackson. + */ +object JsonUtils { + /** + * Jackson ObjectMapper that is used for serializing and deserializing JSON objects. The visibility settings of the + * OBJECT_MAPPER are configured to include all fields when serializing or deserializing objects, regardless of their + * visibility modifiers (public, private, etc.). + */ + val OBJECT_MAPPER: ObjectMapper = JsonMapper.builder() + .visibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE) + .visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .serializationInclusion(JsonInclude.Include.NON_NULL) + .build() + + /** + * Creates a new instance of [JsonFactory] using the default [ObjectMapper]. + */ + fun createFactory() = JsonFactory(OBJECT_MAPPER) + + /** + * Deserializes a JSON string into an object of the given class. + */ + @Throws(JsonProcessingException::class) + @JvmStatic + fun deserialize(json: String, clazz: Class): T = + OBJECT_MAPPER.readValue(json, clazz) + + /** + * Deserializes the contents of the given file into an object of the given class. + */ + @Throws(IOException::class) + fun deserializeFile(file: File, clazz: Class): T = + OBJECT_MAPPER.readValue(file, clazz) + + /** + * Deserializes a JSON string into a list of objects of the given class. + */ + @Throws(JsonProcessingException::class) + @JvmStatic + fun deserializeList(json: String, elementClass: Class): List = + OBJECT_MAPPER.readValue( + json, OBJECT_MAPPER.typeFactory.constructCollectionLikeType(MutableList::class.java, elementClass) + ) + + /** + * Serializes an object into its JSON representation. + */ + @JvmStatic + @Throws(JsonProcessingException::class) + fun serialize(value: Any): String = + OBJECT_MAPPER.writeValueAsString(value) + + /** + * Serializes an object to a file with pretty printing enabled. + */ + @Throws(IOException::class) + fun serializeToFile(file: File, value: T) { + OBJECT_MAPPER.writer().withDefaultPrettyPrinter().writeValue(file, value) + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTest.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTest.kt new file mode 100644 index 000000000..02438878e --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTest.kt @@ -0,0 +1,53 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.* + +/** + * [TestDetails] with information about their partition as well as tracking data used during prioritization of + * tests. Two instances are considered equal if the test details are equals. + */ +data class PrioritizableTest @JsonCreator constructor( + /** The uniform path without the "-test-execution" or "-execution-unit-" prefix */ + @JvmField @param:JsonProperty("testName") var testName: String +) { + /** The uniform path of the test including the "-test-execution" or "-execution-unit-" prefix. */ + var uniformPath: String? = null + + /** The reason the test has been selected. */ + var selectionReason: String? = null + + /** Partition of the test. */ + var partition: String? = null + + /** + * Duration in ms. May be null if not set. This can happen when the uploaded testwise coverage data does not include + * duration information or for new tests that have not been executed yet. + */ + var durationInMs: Long? = null + + /** + * The score determined by the TIA algorithm. The value is guaranteed to be positive. Higher values describe a + * higher probability of the test to detect potential bugs. The value can only express a relative importance + * compared to other scores of the same request. It makes no sense to compare the score against absolute values. + */ + @JsonProperty("currentScore") + var score = 0.0 + + /** + * Field for storing the tests rank. The rank is the 1-based index of the test in the prioritized list. + */ + var rank = 0 + + override fun toString() = + StringJoiner(", ", PrioritizableTest::class.java.simpleName + "[", "]") + .add("testName='$testName'") + .add("uniformPath='$uniformPath'") + .add("selectionReason='$selectionReason'") + .add("partition='$partition'") + .add("durationInMs=$durationInMs") + .add("score=$score") + .add("rank=$rank") + .toString() +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTestCluster.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTestCluster.kt new file mode 100644 index 000000000..2f36c7602 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/PrioritizableTestCluster.kt @@ -0,0 +1,49 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.* + +/** + * A [PrioritizableTestCluster] represents an ordered [List] of [PrioritizableTest]s which should be + * executed together to avoid overhead. The order of the [PrioritizableTest]s is determined by the prioritization + * of the [PrioritizableTest]s w.r.t. to each other. + * + * + * A [PrioritizableTestCluster] assumes that possibly resource intensive setup or teardown operations (e.g. a + * class containing a method annotated with `BeforeClass` in JUnit4 or `BeforeAll` in JUnit5) can be + * executed once for a [PrioritizableTestCluster] instead of executing them for each [PrioritizableTest]. + */ +class PrioritizableTestCluster @JsonCreator constructor( + /** + * The unique cluster id to which all [PrioritizableTest]s belong. + * + * @see ClusteredTestDetails.clusterId + */ + @param:JsonProperty("clusterId") var clusterId: String, + /** The [PrioritizableTest]s in this cluster. */ + @JvmField @param:JsonProperty("tests") var tests: List? +) { + /** + * The score determined by the TIA algorithm. The value is guaranteed to be positive. Higher values describe a + * higher probability of the test to detect potential bugs. The value can only express a relative importance + * compared to other scores of the same request. It makes no sense to compare the score against absolute values. + * The value is 0 if no availableTests are given. + */ + @JsonProperty("currentScore") + var score = 0.0 + + /** + * Field for storing the tests rank. The rank is the 1-based index of the test + * in the prioritized list. + */ + var rank: Int = 0 + + override fun toString() = + StringJoiner(", ", PrioritizableTestCluster::class.java.simpleName + "[", "]") + .add("clusterId='$clusterId'") + .add("score=$score") + .add("rank=$rank") + .add("tests=$tests") + .toString() +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ProcessInformation.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ProcessInformation.kt new file mode 100644 index 000000000..a77dfdd0a --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProcessInformation.kt @@ -0,0 +1,11 @@ +package com.teamscale.client + +/** Information about the process and machine the profiler is attached to. */ +class ProcessInformation( + /** Hostname of the machine it is running on */ + val hostname: String, + /** Profiled PID */ + val pid: String, + /** The timestamp at which the process was started. */ + val startedAtTimestamp: Long +) diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerConfiguration.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerConfiguration.kt new file mode 100644 index 000000000..59326c512 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerConfiguration.kt @@ -0,0 +1,12 @@ +package com.teamscale.client + +/** Configuration options for a profiler. */ +class ProfilerConfiguration { + /** The ID if this configuration. */ + @JvmField + var configurationId: String? = null + + /** The options that should be applied to the profiler. */ + @JvmField + var configurationOptions: String? = null +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerInfo.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerInfo.kt new file mode 100644 index 000000000..86bbec363 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerInfo.kt @@ -0,0 +1,9 @@ +package com.teamscale.client + +/** Information about the profiler including the process it is attached to as well as the configuration it is running with. */ +class ProfilerInfo( + /** Information about the machine and process the profiler is running on. */ + var processInformation: ProcessInformation, + /** Concrete config that the profiler is running with. */ + @JvmField var profilerConfiguration: ProfilerConfiguration? +) diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerLogEntry.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerLogEntry.kt new file mode 100644 index 000000000..bb52f5ada --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerLogEntry.kt @@ -0,0 +1,16 @@ +package com.teamscale.client + +/** A log entry to be sent to Teamscale */ +class ProfilerLogEntry( + /** The time of the event */ + var timestamp: Long, + + /** Log message */ + var message: String, + + /** Details, for example, the stack trace */ + var details: String, + + /** Event severity */ + var severity: String +) \ No newline at end of file diff --git a/teamscale-client/src/main/java/com/teamscale/client/ProfilerRegistration.java b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerRegistration.kt similarity index 54% rename from teamscale-client/src/main/java/com/teamscale/client/ProfilerRegistration.java rename to teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerRegistration.kt index d8e23d96a..bf02c98b7 100644 --- a/teamscale-client/src/main/java/com/teamscale/client/ProfilerRegistration.java +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProfilerRegistration.kt @@ -1,15 +1,16 @@ -package com.teamscale.client; +package com.teamscale.client /** * DTO that is sent to the profiler as a response of registering against * Teamscale and contains the profiler ID that was assigned to it as well as the * configuration it should pick up. */ -public class ProfilerRegistration { +class ProfilerRegistration { + /** The ID that was assigned to this instance of the profiler. */ + @JvmField + var profilerId: String? = null - /** The ID that was assigned to this instance of the profiler. */ - public String profilerId; - - /** The profiler configuration to use. */ - public ProfilerConfiguration profilerConfiguration; + /** The profiler configuration to use. */ + @JvmField + var profilerConfiguration: ProfilerConfiguration? = null } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ProxySystemProperties.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ProxySystemProperties.kt new file mode 100644 index 000000000..d2f846ad9 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProxySystemProperties.kt @@ -0,0 +1,132 @@ +package com.teamscale.client + +/** + * Manages Java system properties for: + * - http.proxyHost + * - http.proxyPort + * - http.proxyUser + * - http.proxyPassword + * + * Or their HTTPS counterparts (https.*). + * These values set the proxy server and credentials used to reach Teamscale. + */ +open class ProxySystemProperties(private val protocol: Protocol) { + + companion object { + private const val PROXY_HOST_SYSTEM_PROPERTY = ".proxyHost" + private const val PROXY_PORT_SYSTEM_PROPERTY = ".proxyPort" + private const val PROXY_USER_SYSTEM_PROPERTY = ".proxyUser" + private const val PROXY_PASSWORD_SYSTEM_PROPERTY = ".proxyPassword" + } + + /** + * Enum representing the supported protocols. + */ + enum class Protocol { + HTTP, HTTPS; + + /** + * Returns the protocol name in lowercase. + */ + override fun toString() = name.lowercase() + } + + /** + * Prefix for the system property keys. + * Can be overridden by subclasses to provide a different prefix. + */ + protected open val propertyPrefix = "" + + /** + * The proxy host system property. + */ + var proxyHost: String? + get() = getProperty(PROXY_HOST_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_HOST_SYSTEM_PROPERTY, value) + } + + /** + * The proxy port system property. + * Must be a positive integer and less than or equal to 65535. + */ + var proxyPort: Int + get() = getProperty(PROXY_PORT_SYSTEM_PROPERTY)?.toIntOrNull() ?: -1 + set(value) { + check(value > 0) { "Port must be a positive integer" } + check(value <= 65535) { "Port must be less than or equal to 65535" } + setProperty(PROXY_PORT_SYSTEM_PROPERTY, value.toString()) + } + + /** + * The proxy user system property. + */ + var proxyUser: String? + get() = getProperty(PROXY_USER_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_USER_SYSTEM_PROPERTY, value) + } + + /** + * The proxy password system property. + */ + var proxyPassword: String? + get() = getProperty(PROXY_PASSWORD_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_PASSWORD_SYSTEM_PROPERTY, value) + } + + /** + * Retrieves the system property value for the given property key. + * + * @param property The property key. + * @return The property value or null if not set. + */ + private fun getProperty(property: String) = + System.getProperty("$propertyPrefix${protocol}.$property") + + /** + * Sets the system property value for the given property key. + * + * @param property The property key. + * @param value The property value to set. + */ + private fun setProperty(property: String, value: String?) { + value?.let { + check(it.isNotBlank()) { "Value must not be blank" } + System.setProperty("$propertyPrefix${protocol}.$property", it) + } + } + + /** + * Checks if the proxy server is set. + * + * @return True if the proxy host and port are set, false otherwise. + */ + fun isProxyServerSet() = !proxyHost.isNullOrEmpty() && proxyPort > 0 + + /** + * Checks if the proxy authentication is set. + * + * @return True if the proxy user and password are set, false otherwise. + */ + fun isProxyAuthSet() = !proxyUser.isNullOrEmpty() && !proxyPassword.isNullOrEmpty() + + /** + * Clears all proxy system properties. + */ + fun clear() { + System.clearProperty("$propertyPrefix${protocol}.$PROXY_HOST_SYSTEM_PROPERTY") + System.clearProperty("$propertyPrefix${protocol}.$PROXY_PORT_SYSTEM_PROPERTY") + System.clearProperty("$propertyPrefix${protocol}.$PROXY_USER_SYSTEM_PROPERTY") + System.clearProperty("$propertyPrefix${protocol}.$PROXY_PASSWORD_SYSTEM_PROPERTY") + } + + /** + * Exception thrown when the port format is incorrect. + * + * @param message The exception message. + * @param cause The cause of the exception. + */ + class IncorrectPortFormatException(message: String, cause: Throwable) : IllegalArgumentException(message, cause) +} \ No newline at end of file diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt new file mode 100644 index 000000000..3dc3e4456 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -0,0 +1,189 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright 2005-2011 The ConQAT Project | +| | +| Licensed under the Apache License, Version 2.0 (the "License"); | +| you may not use this file except in compliance with the License. | +| You may obtain a copy of the License at | +| | +| http://www.apache.org/licenses/LICENSE-2.0 | +| | +| Unless required by applicable law or agreed to in writing, software | +| distributed under the License is distributed on an "AS IS" BASIS, | +| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | +| See the License for the specific language governing permissions and | +| limitations under the License. | ++-------------------------------------------------------------------------*/ +package com.teamscale.client + +import java.text.NumberFormat + +/** + * A utility class providing some advanced string functionality. + */ +object StringUtils { + /** Line separator of the current platform. */ + private val LINE_SEPARATOR: String = System.lineSeparator() + + /** The empty string. */ + private const val EMPTY_STRING: String = "" + + /** + * Checks if a string is empty (after trimming). + * + * @param text the string to check. + * @return `true` if string is empty or `null`, + * `false` otherwise. + */ + @JvmStatic + fun isEmpty(text: String?): Boolean { + if (text == null) { + return true + } + return EMPTY_STRING == text.trim { it <= ' ' } + } + + /** + * Determine if the supplied [String] is *blank* (i.e., `null` or consisting only of whitespace + * characters). + * + * @param str the string to check; may be `null` + * @return `true` if the string is blank + */ + @JvmStatic + fun isBlank(str: String?) = (str == null || str.trim { it <= ' ' }.isEmpty()) + + /** + * Returns the beginning of a String, cutting off the last part which is separated by the given character. + * + * + * E.g., removeLastPart("org.conqat.lib.commons.string.StringUtils", '.') gives "org.conqat.lib.commons.string". + * + * @param string the String + * @param separator separation character + * @return the String without the last part, or the original string if the separation character is not found. + */ + fun removeLastPart(string: String, separator: Char): String { + val idx = string.lastIndexOf(separator) + if (idx == -1) { + return string + } + + return string.substring(0, idx) + } + + /** + * Remove prefix from a string. + * + * @param string the string + * @param prefix the prefix + * @return the string without the prefix or the original string if it does not start with the prefix. + */ + @JvmStatic + fun stripPrefix(string: String, prefix: String): String { + if (string.startsWith(prefix)) { + return string.substring(prefix.length) + } + return string + } + + /** + * Remove suffix from a string. + * + * @param string the string + * @param suffix the suffix + * @return the string without the suffix or the original string if it does not end with the suffix. + */ + @JvmStatic + fun stripSuffix(string: String, suffix: String): String { + if (string.endsWith(suffix)) { + return string.substring(0, string.length - suffix.length) + } + return string + } + + /** + * Create string representation of a map. + * + * @param map the map + * @param indent a line indent + */ + /** + * Create string representation of a map. + */ + @JvmOverloads + fun toString(map: Map<*, *>, indent: String? = EMPTY_STRING): String { + val result = StringBuilder() + val keyIterator = map.keys.iterator() + + while (keyIterator.hasNext()) { + result.append(indent) + val key = keyIterator.next()!! + result.append(key) + result.append(" = ") + result.append(map[key]) + if (keyIterator.hasNext()) { + result.append(LINE_SEPARATOR) + } + } + + return result.toString() + } + + /** + * Format number with number formatter, if number formatter is + * `null`, this uses [String.valueOf]. + */ + fun format(number: Double, numberFormat: NumberFormat?): String { + if (numberFormat == null) { + return number.toString() + } + return numberFormat.format(number) + } + + /** + * Calculates the Levenshtein distance between this CharSequence and another CharSequence. + * The Levenshtein distance is a measure of the number of single-character edits (insertions, deletions, or substitutions) + * required to change one string into the other. + * + * This implementation has a time complexity of O(n * m) and a space complexity of O(n), where n and m are the lengths + * of the two strings. + * + * For more information, see [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance). + * + * @receiver The string to compare. + * @param rhs The string to compare against. + * @return The Levenshtein distance between the two strings. + */ + @JvmStatic + fun CharSequence.levenshteinDistance(rhs: CharSequence): Int { + if (this == rhs) return 0 + if (isEmpty()) return rhs.length + if (rhs.isEmpty()) return length + + val len0 = length + 1 + val len1 = rhs.length + 1 + + var cost = IntArray(len0) { it } + var newCost = IntArray(len0) { 0 } + + (1.. + newCost[0] = i + + (1.. + val match = if (this[j - 1] == rhs[i - 1]) 0 else 1 + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = minOf(costInsert, costDelete, costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[len0 - 1] + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt new file mode 100644 index 000000000..4d95d8772 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleClient.kt @@ -0,0 +1,255 @@ +package com.teamscale.client + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MultipartBody +import okhttp3.MultipartBody.Companion.FORM +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Response +import java.io.File +import java.io.IOException +import java.time.Duration +import java.util.* + +/** Helper class to interact with Teamscale. */ +open class TeamscaleClient { + /** Teamscale service implementation. */ + var service: ITeamscaleService + + /** The project ID within Teamscale. */ + private val projectId: String + + /** Constructor with parameters for read and write timeout in seconds. */ + @JvmOverloads + constructor( + baseUrl: String?, + user: String, + accessToken: String, + projectId: String, + readTimeout: Duration = HttpUtils.DEFAULT_READ_TIMEOUT, + writeTimeout: Duration = HttpUtils.DEFAULT_WRITE_TIMEOUT + ) { + val url = baseUrl?.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid URL: $baseUrl") + this.projectId = projectId + service = TeamscaleServiceGenerator.createService( + ITeamscaleService::class.java, url, user, accessToken, readTimeout, writeTimeout + ) + } + + /** Constructor with parameters for read and write timeout in seconds and logfile. */ + @JvmOverloads + constructor( + baseUrl: String?, + user: String, + accessToken: String, + projectId: String, + logfile: File?, + readTimeout: Duration = HttpUtils.DEFAULT_READ_TIMEOUT, + writeTimeout: Duration = HttpUtils.DEFAULT_WRITE_TIMEOUT + ) { + val url = baseUrl?.toHttpUrlOrNull() ?: throw IllegalArgumentException("Invalid URL: $baseUrl") + this.projectId = projectId + service = TeamscaleServiceGenerator.createServiceWithRequestLogging( + ITeamscaleService::class.java, url, user, accessToken, logfile, readTimeout, writeTimeout + ) + } + + /** + * Tries to retrieve the impacted tests from Teamscale. This should be used in a CI environment, because it ensures + * that the given commit has been processed by Teamscale and also considers previous failing tests for + * re-execution. + * + * @param availableTests A list of tests that is locally available for execution. This allows TIA to consider newly + * added tests in addition to those that are already known and allows to filter e.g. if the + * user has already selected a subset of relevant tests. This can be `null` to + * indicate that only tests known to Teamscale should be suggested. + * @param baseline The baseline timestamp AFTER which changes should be considered. Changes that happened + * exactly at the baseline will be excluded. In case you want to retrieve impacted tests for a + * single commit with a known timestamp you can append a `"p1"` suffix to the + * timestamp to indicate that you are interested in the changes that happened after the parent + * of the given commit. + * @param baselineRevision Same as baseline but accepts a revision (e.g. git SHA1) instead of a branch and timestamp + * @param endCommit The last commit for which changes should be considered. + * @param endRevision Same as endCommit but accepts a revision (e.g. git SHA1) instead of a branch and timestamp + * @param repository The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. + * Null or empty will lead to a lookup in all repositories in the Teamscale project. + * @param partitions The partitions that should be considered for retrieving impacted tests. Can be + * `null` to indicate that tests from all partitions should be returned. + * @return A list of test clusters to execute. If availableTests is null, a single dummy cluster is returned with + * all prioritized tests. + */ + @Throws(IOException::class) + open fun getImpactedTests( + availableTests: List?, + baseline: String?, + baselineRevision: String?, + endCommit: CommitDescriptor?, + endRevision: String?, + repository: String?, + partitions: List, + includeNonImpacted: Boolean, + includeAddedTests: Boolean, + includeFailedAndSkipped: Boolean + ): Response?> { + val selectedOptions = mutableListOf(ETestImpactOptions.ENSURE_PROCESSED) + if (includeNonImpacted) { + selectedOptions.add(ETestImpactOptions.INCLUDE_NON_IMPACTED) + } + if (includeAddedTests) { + selectedOptions.add(ETestImpactOptions.INCLUDE_ADDED_TESTS) + } + if (includeFailedAndSkipped) { + selectedOptions.add(ETestImpactOptions.INCLUDE_FAILED_AND_SKIPPED) + } + return getImpactedTests( + availableTests, baseline, baselineRevision, endCommit, endRevision, repository, partitions, + *selectedOptions.toTypedArray() + ) + } + + /** + * Tries to retrieve the impacted tests from Teamscale. Use this method if you want to query time range based or you + * want to exclude failed and skipped tests from previous test runs. + * + * @param availableTests A list of tests that is locally available for execution. This allows TIA to consider newly + * added tests in addition to those that are already known and allows to filter e.g. if the + * user has already selected a subset of relevant tests. This can be `null` to + * indicate that only tests known to Teamscale should be suggested. + * @param baseline The baseline timestamp AFTER which changes should be considered. Changes that happened + * exactly at the baseline will be excluded. In case you want to retrieve impacted tests for a + * single commit with a known timestamp you can append a `"p1"` suffix to the + * timestamp to indicate that you are interested in the changes that happened after the parent + * of the given commit. + * @param baselineRevision Same as baseline but accepts a revision (e.g. git SHA1) instead of a branch and timestamp + * @param endCommit The last commit for which changes should be considered. + * @param endRevision Same as endCommit but accepts a revision (e.g. git SHA1) instead of a branch and timestamp + * @param repository The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. + * Null or empty will lead to a lookup in all repositories in the Teamscale project. + * @param partitions The partitions that should be considered for retrieving impacted tests. Can be + * `null` to indicate that tests from all partitions should be returned. + * @param options A list of options (See [ETestImpactOptions] for more details) + * @return A list of test clusters to execute. If [availableTests] is null, a single dummy cluster is returned with + * all prioritized tests. + */ + @Throws(IOException::class) + private fun getImpactedTests( + availableTests: List?, + baseline: String?, + baselineRevision: String?, + endCommit: CommitDescriptor?, + endRevision: String?, + repository: String?, + partitions: List, + vararg options: ETestImpactOptions + ): Response?> { + val testImpactOptions = EnumSet.copyOf(listOf(*options)) + val includeNonImpacted = testImpactOptions.contains(ETestImpactOptions.INCLUDE_NON_IMPACTED) + val includeFailedAndSkipped = testImpactOptions.contains(ETestImpactOptions.INCLUDE_FAILED_AND_SKIPPED) + val ensureProcessed = testImpactOptions.contains(ETestImpactOptions.ENSURE_PROCESSED) + val includeAddedTests = testImpactOptions.contains(ETestImpactOptions.INCLUDE_ADDED_TESTS) + + return if (availableTests == null) { + wrapInCluster( + service.getImpactedTests( + projectId, baseline, baselineRevision, endCommit, endRevision, repository, partitions, + includeNonImpacted, + includeFailedAndSkipped, + ensureProcessed, includeAddedTests + ).execute() + ) + } else { + val availableTestsMap = availableTests.map { clusteredTestDetails -> + TestWithClusterId.fromClusteredTestDetails(clusteredTestDetails) + } + service.getImpactedTests( + projectId, baseline, baselineRevision, endCommit, endRevision, repository, partitions, + includeNonImpacted, includeFailedAndSkipped, ensureProcessed, includeAddedTests, availableTestsMap + ).execute() + } + } + + /** Uploads multiple reports to Teamscale in the given [EReportFormat]. */ + @Throws(IOException::class) + open fun uploadReports( + reportFormat: EReportFormat, + reports: Collection, + commitDescriptor: CommitDescriptor?, + revision: String?, + repository: String?, + partition: String, + message: String + ) { + uploadReports(reportFormat.name, reports, commitDescriptor, revision, repository, partition, message) + } + + /** Uploads multiple reports to Teamscale. */ + @Throws(IOException::class) + open fun uploadReports( + reportFormat: String, + reports: Collection, + commitDescriptor: CommitDescriptor?, + revision: String?, + repository: String?, + partition: String, + message: String + ) { + val partList = reports.map { file -> + val requestBody = file.asRequestBody(FORM) + MultipartBody.Part.createFormData("report", file.name, requestBody) + } + + val response = service + .uploadExternalReports( + projectId, reportFormat, commitDescriptor, revision, repository, true, partition, message, partList + ).execute() + if (!response.isSuccessful) { + throw IOException("HTTP request failed: " + HttpUtils.getErrorBodyStringSafe(response)) + } + } + + /** Uploads one in-memory report to Teamscale. */ + @Throws(IOException::class) + open fun uploadReport( + reportFormat: EReportFormat, + report: String, + commitDescriptor: CommitDescriptor?, + revision: String?, + repository: String?, + partition: String, + message: String + ) { + service.uploadReport( + projectId, + commitDescriptor, + revision, + repository, + partition, + reportFormat, + message, + report.toRequestBody(FORM) + ) + } + + companion object { + private fun wrapInCluster( + testListResponse: Response> + ): Response?> { + return if (testListResponse.isSuccessful) { + Response.success( + listOf( + PrioritizableTestCluster( + "dummy", + testListResponse.body() + ) + ), + testListResponse.raw() + ) + } else { + Response.error( + testListResponse.errorBody()!!, + testListResponse.raw() + ) + } + } + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleProxySystemProperties.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleProxySystemProperties.kt new file mode 100644 index 000000000..18dd5cb0d --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleProxySystemProperties.kt @@ -0,0 +1,21 @@ +package com.teamscale.client + +/** + * Reads and writes Java system properties values for + * + * * teamscale.http.proxyHost + * * teamscale.http.proxyPort + * * teamscale.http.proxyUser + * * teamscale.http.proxyPassword + * + * or the corresponding HTTPS counterpart (starting with https instead of http). + * These values set the proxy server and credentials that should be used later to reach Teamscale and take precedence + * over the default proxy settings (see [ProxySystemProperties.ProxySystemProperties]). + */ +class TeamscaleProxySystemProperties(protocol: Protocol) : ProxySystemProperties(protocol) { + /** + * The prefix for Teamscale system properties. + */ + override val propertyPrefix: String + get() = "teamscale." +} \ No newline at end of file diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServer.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServer.kt new file mode 100644 index 000000000..05097b1e8 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServer.kt @@ -0,0 +1,177 @@ +package com.teamscale.client + +import okhttp3.HttpUrl +import java.net.InetAddress +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +/** Holds Teamscale server details. */ +class TeamscaleServer { + /** The URL of the Teamscale server. */ + @JvmField + var url: HttpUrl? = null + + /** The project id within Teamscale. */ + @JvmField + var project: String? = null + + /** The username used to authenticate against Teamscale. */ + @JvmField + var userName: String? = null + + /** The user's access token. */ + @JvmField + var userAccessToken: String? = null + + /** The partition to upload reports to. */ + @JvmField + var partition: String? = null + + /** + * The corresponding code commit to which the coverage belongs. If this is null, the Agent is supposed to + * auto-detect the commit from the profiled code. + */ + @JvmField + var commit: CommitDescriptor? = null + + /** + * The corresponding code revision to which the coverage belongs. This is currently only supported for testwise + * mode. + */ + @JvmField + var revision: String? = null + + /** + * The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. + * Null or empty will lead to a lookup in all repositories in the Teamscale project. + */ + @JvmField + var repository: String? = null + + /** + * The configuration ID that was used to retrieve the profiler configuration. This is only set here to append it to + * the default upload message. + */ + @JvmField + var configId: String? = null + + /** + * The commit message shown in the Teamscale UI for the coverage upload. If the message is null, auto-generates a + * sensible message. + */ + var message: String? = null + get() { + if (field == null) { + return buildDefaultMessage() + } + return field + } + + /** + * We do not include the IP address here as one host may have + * - multiple network interfaces + * - each with multiple IP addresses + * - in either IPv4 or IPv6 format + * - and it is unclear which of those is "the right one" or even just which is useful (e.g. loopback or virtual + * adapters are not useful and might even confuse readers) + */ + private fun buildDefaultMessage() = + buildString { + append("$partition coverage uploaded at ") + append(DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now())) + append("\n\nuploaded from ") + + val hostname = runCatching { + "hostname: " + InetAddress.getLocalHost().hostName + }.getOrElse { + "an unknown computer" + } + append(hostname) + + if (revision != null) { + append("\nfor revision: $revision") + } + + if (configId != null) { + append("\nprofiler configuration ID: $configId") + } + } + + /** Checks if all fields required for a single-project Teamscale upload are non-null. */ + val isConfiguredForSingleProjectTeamscaleUpload: Boolean + get() = isConfiguredForServerConnection && partition != null && project != null + + /** Checks if all fields required for a Teamscale upload are non-null, except the project which must be null. */ + val isConfiguredForMultiProjectUpload: Boolean + get() = isConfiguredForServerConnection && partition != null && project == null + + /** Checks if all required fields to access a Teamscale server are non-null. */ + val isConfiguredForServerConnection: Boolean + get() = url != null && userName != null && userAccessToken != null + + /** Whether a URL, user and access token were provided. */ + fun canConnectToTeamscale() = + url != null && userName != null && userAccessToken != null + + /** Returns whether all fields are null. */ + fun hasAllFieldsNull() = + url == null + && project == null + && userName == null + && userAccessToken == null + && partition == null + && commit == null + && revision == null + + /** Returns whether either a commit or revision has been set. */ + fun hasCommitOrRevision() = + commit != null || revision != null + + /** Checks if another TeamscaleServer has the same project and revision/commit as this TeamscaleServer instance. */ + fun hasSameProjectAndCommit(other: TeamscaleServer): Boolean { + if (project != other.project) { + return false + } + if (revision != null) { + return revision == other.revision + } + return commit == other.commit + } + + override fun toString() = + buildString { + append("Teamscale $url as user $userName for $project to $partition at ") + if (revision != null) { + append("revision $revision") + if (repository != null) { + append(" in repository $repository") + } + } else { + append("commit $commit") + } + } + + /** Creates a copy of the [TeamscaleServer] configuration, but with the given project and commit set. */ + fun withProjectAndCommit(teamscaleProject: String, commitDescriptor: CommitDescriptor): TeamscaleServer { + val teamscaleServer = TeamscaleServer() + teamscaleServer.url = url + teamscaleServer.userName = userName + teamscaleServer.userAccessToken = userAccessToken + teamscaleServer.partition = partition + teamscaleServer.project = teamscaleProject + teamscaleServer.commit = commitDescriptor + return teamscaleServer + } + + /** Creates a copy of the [TeamscaleServer] configuration, but with the given project and revision set. */ + fun withProjectAndRevision(project: String, revision: String): TeamscaleServer { + val server = TeamscaleServer() + server.url = url + server.userName = userName + server.userAccessToken = userAccessToken + server.partition = partition + server.project = project + server.revision = revision + return server + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt new file mode 100644 index 000000000..c8c450cbf --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleServiceGenerator.kt @@ -0,0 +1,91 @@ +package com.teamscale.client + +import okhttp3.* +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory +import java.io.File +import java.io.IOException +import java.time.Duration + +/** Helper class for generating a teamscale compatible service. */ +object TeamscaleServiceGenerator { + /** Custom user agent of the requests, used to monitor API traffic. */ + const val USER_AGENT = "Teamscale JaCoCo Agent" + + /** + * Generates a [Retrofit] instance for the given service, which uses basic auth to authenticate against the + * server and which sets the accept header to json. + */ + @JvmStatic + @JvmOverloads + fun createService( + serviceClass: Class, + baseUrl: HttpUrl, + username: String, + accessToken: String, + readTimeout: Duration = HttpUtils.DEFAULT_READ_TIMEOUT, + writeTimeout: Duration = HttpUtils.DEFAULT_WRITE_TIMEOUT, + vararg interceptors: Interceptor + ) = createServiceWithRequestLogging( + serviceClass, baseUrl, username, accessToken, null, readTimeout, writeTimeout, *interceptors + ) + + /** + * Generates a [Retrofit] instance for the given service, which uses basic auth to authenticate against the + * server and which sets the accept-header to json. Logs requests and responses to the given logfile. + */ + fun createServiceWithRequestLogging( + serviceClass: Class, + baseUrl: HttpUrl, + username: String, + accessToken: String, + logfile: File?, + readTimeout: Duration, + writeTimeout: Duration, + vararg interceptors: Interceptor + ): S = HttpUtils.createRetrofit( + { retrofitBuilder -> + retrofitBuilder.baseUrl(baseUrl) + .addConverterFactory(JacksonConverterFactory.create(JsonUtils.OBJECT_MAPPER)) + }, + { okHttpBuilder -> + okHttpBuilder.addInterceptors(*interceptors) + .addInterceptor(HttpUtils.getBasicAuthInterceptor(username, accessToken)) + .addInterceptor(AcceptJsonInterceptor()) + .addNetworkInterceptor(CustomUserAgentInterceptor()) + logfile?.let { okHttpBuilder.addInterceptor(FileLoggingInterceptor(it)) } + }, + readTimeout, writeTimeout + ).create(serviceClass) + + private fun OkHttpClient.Builder.addInterceptors( + vararg interceptors: Interceptor + ): OkHttpClient.Builder { + interceptors.forEach { interceptor -> + addInterceptor(interceptor) + } + return this + } + + /** + * Sets an `Accept: application/json` header on all requests. + */ + private class AcceptJsonInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request().newBuilder().header("Accept", "application/json").build() + return chain.proceed(newRequest) + } + } + + /** + * Sets the custom user agent [.USER_AGENT] header on all requests. + */ + class CustomUserAgentInterceptor : Interceptor { + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request().newBuilder().header("User-Agent", USER_AGENT).build() + return chain.proceed(newRequest) + } + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TestData.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TestData.kt new file mode 100644 index 000000000..767ef949d --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TestData.kt @@ -0,0 +1,75 @@ +package com.teamscale.client + +import com.teamscale.client.TestData.Builder +import org.apache.commons.codec.binary.Hex +import org.apache.commons.codec.digest.DigestUtils +import java.io.IOException +import java.nio.file.Path + +/** + * Represents additional test data to attach to [ClusteredTestDetails]. Use the [Builder] to create new + * [TestData] objects. + * + * Internally, the data you pass to the builder is hashed and only the hash is transferred as [ClusteredTestDetails.content] + * to Teamscale to save network bandwidth and RAM. Whenever a test case's hash changes, + * Teamscale will select it for the next TIA test run. + */ +class TestData private constructor( + /** The hash of the test data which will be sent to Teamscale as the [ClusteredTestDetails.content]. */ + val hash: String +) { + /** + * Builder for [TestData] objects. This class is thread-safe and ensures that reading the test data does not + * result in [OutOfMemoryError]s. + */ + class Builder { + private var digest = DigestUtils.getSha1Digest() + + /** Adds the given bytes as additional test data. */ + @Synchronized + fun addByteArray(content: ByteArray?): Builder { + ensureHasNotBeenFinalized() + DigestUtils.updateDigest(digest, content) + DigestUtils.updateDigest(digest, DIGEST_SEPARATOR) + return this + } + + private fun ensureHasNotBeenFinalized() { + checkNotNull(digest) { "You tried to use this TestData.Builder after calling #build() on it. Builders cannot be reused." } + } + + /** Adds the given String as additional test data. */ + @Synchronized + fun addString(content: String?): Builder { + ensureHasNotBeenFinalized() + DigestUtils.updateDigest(digest, content) + DigestUtils.updateDigest(digest, DIGEST_SEPARATOR) + return this + } + + /** Adds the contents of the given file path as additional test data. */ + @Synchronized + @Throws(IOException::class) + fun addFileContent(fileWithContent: Path): Builder { + ensureHasNotBeenFinalized() + DigestUtils.updateDigest(digest, fileWithContent) + DigestUtils.updateDigest(digest, DIGEST_SEPARATOR) + return this + } + + /** + * Builds the [TestData] object. After calling this method, you cannot use this builder anymore. + */ + @Synchronized + fun build(): TestData { + ensureHasNotBeenFinalized() + val hash = Hex.encodeHexString(digest!!.digest()) + digest = null + return TestData(hash) + } + + companion object { + private val DIGEST_SEPARATOR = "-!#!-".toByteArray() + } + } +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TestDetails.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TestDetails.kt new file mode 100644 index 000000000..c24a8ada5 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TestDetails.kt @@ -0,0 +1,40 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import java.util.* + +/** + * Contains details about a test. + */ +open class TestDetails @JsonCreator constructor( + /** Unique name of the test case by using a path like hierarchical description, which can be shown in the UI. */ + @JvmField @param:JsonProperty("uniformPath") var uniformPath: String, + /** + * Path to the source of the method. Will be equal to uniformPath in most cases, but e.g. @Test methods in a base + * class will have the sourcePath pointing to the base class which contains the actual implementation whereas + * uniformPath will contain the the class name of the most specific subclass, from where it was actually executed. + */ + @JvmField @param:JsonProperty("sourcePath") var sourcePath: String?, + /** + * Some kind of content to tell whether the test specification has changed. Can be revision number or hash over the + * specification or similar. You can include e.g. a hash of each test's test data so that whenever the test data + * changes, the corresponding test is re-run. + */ + @param:JsonProperty("content") var content: String? +) { + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (other == null || javaClass != other.javaClass) { + return false + } + val that = other as TestDetails + return uniformPath == that.uniformPath && + sourcePath == that.sourcePath && + content == that.content + } + + override fun hashCode() = Objects.hash(uniformPath, sourcePath, content) +} diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/TestWithClusterId.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/TestWithClusterId.kt new file mode 100644 index 000000000..bf217be7f --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TestWithClusterId.kt @@ -0,0 +1,37 @@ +package com.teamscale.client + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Test with additional information about which cluster of tests the test case belongs to during prioritization. + */ +class TestWithClusterId @JsonCreator constructor( + /** + * The uniform path of the test (unescaped and without -test-execution- prefix). + */ + @param:JsonProperty("testName") val testName: String, + /** + * The hashed content of the test. + */ + @param:JsonProperty("hash") val hash: String?, + /** + * The partition of the test. + */ + @param:JsonProperty("partition") val partition: String?, + /** + * A unique identifier for the cluster this test should be prioritized within. May not be null. + */ + @param:JsonProperty("clusterId") val clusterId: String? +) { + companion object { + /** + * Creates a #TestWithClusterId from a #ClusteredTestDetails object. + */ + fun fromClusteredTestDetails(clusteredTestDetails: ClusteredTestDetails) = + TestWithClusterId( + clusteredTestDetails.uniformPath, clusteredTestDetails.content, + clusteredTestDetails.partition, clusteredTestDetails.clusterId + ) + } +} diff --git a/teamscale-client/src/test/java/com/teamscale/client/ProxySystemPropertiesTest.java b/teamscale-client/src/test/java/com/teamscale/client/ProxySystemPropertiesTest.java deleted file mode 100644 index 84ce8d74a..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/ProxySystemPropertiesTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.teamscale.client; - -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; - -class ProxySystemPropertiesTest { - - private static ProxySystemProperties properties = new ProxySystemProperties(ProxySystemProperties.Protocol.HTTP); - - @AfterAll - static void teardown() { - properties.removeProxyPort(); - } - - @Test - void testPortParsing() { - properties.setProxyPort(9876); - Assertions.assertThat(properties.getProxyPort()).isEqualTo(9876); - properties.setProxyPort(""); - Assertions.assertThat(properties.getProxyPort()).isEqualTo(-1); - properties.setProxyPort("nonsense"); - Assertions.assertThat(properties.getProxyPort()).isEqualTo(-1); - properties.removeProxyPort(); - Assertions.assertThat(properties.getProxyPort()).isEqualTo(-1); - } - -} \ No newline at end of file diff --git a/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServerTest.java b/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServerTest.java deleted file mode 100644 index ef02d847d..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServerTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.teamscale.client; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class TeamscaleServerTest { - - @Test - void testDefaultMessage() { - TeamscaleServer server = new TeamscaleServer(); - server.partition = "Unit Test"; - server.revision = "rev123"; - - String message = server.getMessage(); - String normalizedMessage = message.replaceAll("uploaded at .*", "uploaded at DATE") - .replaceAll("hostname: .*", "hostname: HOST"); - assertEquals("Unit Test coverage uploaded at DATE\n\nuploaded from hostname: HOST\nfor revision: rev123", - normalizedMessage); - } - -} \ No newline at end of file diff --git a/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.java b/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.java deleted file mode 100644 index 07024df05..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.teamscale.client; - -import okhttp3.HttpUrl; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -import static com.teamscale.client.HttpUtils.PROXY_AUTHORIZATION_HTTP_HEADER; -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests that our Retrofit + OkHttp client is using the Java proxy system properties ({@code http.proxy*}) if set - */ -class TeamscaleServiceGeneratorProxyServerTest { - - private MockWebServer mockProxyServer; - private final ProxySystemProperties proxySystemProperties = new ProxySystemProperties( - ProxySystemProperties.Protocol.HTTP); - - @BeforeEach - void setUp() throws IOException { - mockProxyServer = new MockWebServer(); - mockProxyServer.start(); - } - - @Test - void testProxyAuthentication() throws IOException, InterruptedException { - String proxyUser = "myProxyUser"; - String proxyPassword = "myProxyPassword"; - String base64EncodedBasicAuth = Base64.getEncoder().encodeToString((proxyUser + ":" + proxyPassword).getBytes( - StandardCharsets.UTF_8)); - proxySystemProperties.setProxyHost(mockProxyServer.getHostName()); - proxySystemProperties.setProxyPort(mockProxyServer.getPort()); - proxySystemProperties.setProxyUser(proxyUser); - proxySystemProperties.setProxyPassword(proxyPassword); - - ITeamscaleService service = TeamscaleServiceGenerator.createService(ITeamscaleService.class, - HttpUrl.parse("http://localhost:1337"), - "someUser", "someAccesstoken", HttpUtils.DEFAULT_READ_TIMEOUT, - HttpUtils.DEFAULT_WRITE_TIMEOUT); - - // First time Retrofit/OkHttp tires without proxy auth. - // When we return 407 Proxy Authentication Required, it retries with proxy authentication. - mockProxyServer.enqueue(new MockResponse().setResponseCode(407)); - mockProxyServer.enqueue(new MockResponse().setResponseCode(200)); - service.sendHeartbeat("", new ProfilerInfo(new ProcessInformation("", "", 0), null)).execute(); - - assertThat(mockProxyServer.getRequestCount()).isEqualTo(2); - - mockProxyServer.takeRequest(); // First request which doesn't have the proxy authentication set yet - RecordedRequest requestWithProxyAuth = mockProxyServer.takeRequest();// Request we are actually interested in - - assertThat(requestWithProxyAuth.getHeader(PROXY_AUTHORIZATION_HTTP_HEADER)).isEqualTo( - "Basic " + base64EncodedBasicAuth); - } - - @AfterEach - void tearDown() throws IOException { - proxySystemProperties.setProxyHost(""); - proxySystemProperties.setProxyPort(""); - proxySystemProperties.setProxyUser(""); - proxySystemProperties.setProxyPassword(""); - - mockProxyServer.shutdown(); - mockProxyServer.close(); - } -} \ No newline at end of file diff --git a/teamscale-client/src/test/java/com/teamscale/client/TestDataTest.java b/teamscale-client/src/test/java/com/teamscale/client/TestDataTest.java deleted file mode 100644 index 095acd88e..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/TestDataTest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.teamscale.client; - -import org.junit.jupiter.api.Test; - -class TestDataTest { - - @Test - public void ensureHashingDoesNotThrowException() { - new TestData.Builder().addByteArray(new byte[]{1, 2, 3}).addString("string").build(); - } - -} \ No newline at end of file diff --git a/teamscale-client/src/test/kotlin/com/teamscale/client/ProxySystemPropertiesTest.kt b/teamscale-client/src/test/kotlin/com/teamscale/client/ProxySystemPropertiesTest.kt new file mode 100644 index 000000000..dac5a70b8 --- /dev/null +++ b/teamscale-client/src/test/kotlin/com/teamscale/client/ProxySystemPropertiesTest.kt @@ -0,0 +1,32 @@ +package com.teamscale.client + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test + +internal class ProxySystemPropertiesTest { + @Test + fun testPortParsing() { + properties.proxyPort = 9876 + assertThat(properties.proxyPort).isEqualTo(9876) + assertThatThrownBy { + properties.proxyPort = 0 + }.hasMessage("Port must be a positive integer") + assertThatThrownBy { + properties.proxyPort = 65536 + }.hasMessage("Port must be less than or equal to 65535") + properties.clear() + assertThat(properties.proxyPort).isEqualTo(-1) + } + + companion object { + private val properties = ProxySystemProperties(ProxySystemProperties.Protocol.HTTP) + + @JvmStatic + @AfterAll + fun teardown() { + properties.clear() + } + } +} \ No newline at end of file diff --git a/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServerTest.kt b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServerTest.kt new file mode 100644 index 000000000..0ac7c680e --- /dev/null +++ b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServerTest.kt @@ -0,0 +1,21 @@ +package com.teamscale.client + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +internal class TeamscaleServerTest { + @Test + fun testDefaultMessage() { + val server = TeamscaleServer() + server.partition = "Unit Test" + server.revision = "rev123" + + val normalizedMessage = server.message + ?.replace("uploaded at .*".toRegex(), "uploaded at DATE") + ?.replace("hostname: .*".toRegex(), "hostname: HOST") + Assertions.assertEquals( + "Unit Test coverage uploaded at DATE\n\nuploaded from hostname: HOST\nfor revision: rev123", + normalizedMessage + ) + } +} \ No newline at end of file diff --git a/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt new file mode 100644 index 000000000..2e91476a6 --- /dev/null +++ b/teamscale-client/src/test/kotlin/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.kt @@ -0,0 +1,94 @@ +package com.teamscale.client + +import com.teamscale.client.HttpUtils.PROXY_AUTHORIZATION_HTTP_HEADER +import com.teamscale.client.TeamscaleServiceGenerator.createService +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * Tests that our Retrofit + OkHttp client is using the Java proxy system properties (`http.proxy*`) if set + */ +internal class TeamscaleServiceGeneratorProxyServerTest { + private var mockProxyServer: MockWebServer? = null + private val proxySystemProperties = ProxySystemProperties( + ProxySystemProperties.Protocol.HTTP + ) + private val teamscaleProxySystemProperties = TeamscaleProxySystemProperties( + ProxySystemProperties.Protocol.HTTP + ) + + @BeforeEach + @Throws(IOException::class) + fun setUp() { + mockProxyServer = MockWebServer() + mockProxyServer?.start() + } + + @Test + @Throws(Exception::class) + fun testTeamscaleProxyAuthentication() { + val incorrectValue = "incorrect" + // the teamscale-specific options should take precedence over the global ones + proxySystemProperties.proxyHost = incorrectValue + proxySystemProperties.proxyPort = 1 + proxySystemProperties.proxyUser = incorrectValue + proxySystemProperties.proxyPassword = incorrectValue + + teamscaleProxySystemProperties.proxyHost = mockProxyServer?.hostName + teamscaleProxySystemProperties.proxyPort = mockProxyServer?.port ?: 1 + + val proxyUser = "myProxyUser" + val proxyPassword = "myProxyPassword" + val base64EncodedBasicAuth = Base64.getEncoder().encodeToString( + ("$proxyUser:$proxyPassword").toByteArray(StandardCharsets.UTF_8) + ) + teamscaleProxySystemProperties.proxyUser = proxyUser + teamscaleProxySystemProperties.proxyPassword = proxyPassword + + assertProxyAuthenticationIsUsed(base64EncodedBasicAuth) + } + + @Throws(InterruptedException::class, IOException::class) + private fun assertProxyAuthenticationIsUsed(base64EncodedBasicAuth: String) { + val service = createService( + ITeamscaleService::class.java, + "http://localhost:1337".toHttpUrl(), + "someUser", "someAccesstoken" + ) + + // First time Retrofit/OkHttp tires without proxy auth. + // When we return 407 Proxy Authentication Required, it retries with proxy authentication. + mockProxyServer?.enqueue(MockResponse().setResponseCode(407)) + mockProxyServer?.enqueue(MockResponse().setResponseCode(200)) + service.sendHeartbeat( + "", + ProfilerInfo(ProcessInformation("", "", 0), null) + ).execute() + + Assertions.assertThat(mockProxyServer?.requestCount).isEqualTo(2) + + mockProxyServer?.takeRequest() // First request which doesn't have the proxy authentication set yet + val requestWithProxyAuth = mockProxyServer?.takeRequest() // Request we are actually interested in + + Assertions.assertThat(requestWithProxyAuth?.getHeader(PROXY_AUTHORIZATION_HTTP_HEADER)) + .isEqualTo("Basic $base64EncodedBasicAuth") + } + + @AfterEach + @Throws(IOException::class) + fun tearDown() { + proxySystemProperties.clear() + teamscaleProxySystemProperties.clear() + + mockProxyServer?.shutdown() + mockProxyServer?.close() + } +} \ No newline at end of file diff --git a/teamscale-client/src/test/kotlin/com/teamscale/client/TestDataTest.kt b/teamscale-client/src/test/kotlin/com/teamscale/client/TestDataTest.kt new file mode 100644 index 000000000..6b39c2f6e --- /dev/null +++ b/teamscale-client/src/test/kotlin/com/teamscale/client/TestDataTest.kt @@ -0,0 +1,10 @@ +package com.teamscale.client + +import org.junit.jupiter.api.Test + +internal class TestDataTest { + @Test + fun ensureHashingDoesNotThrowException() { + TestData.Builder().addByteArray(byteArrayOf(1, 2, 3)).addString("string").build() + } +} \ No newline at end of file diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt index 3ca610e42..8344e36dc 100755 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt @@ -1,13 +1,14 @@ package com.teamscale +import com.teamscale.client.EReportFormat import com.teamscale.client.TeamscaleClient import com.teamscale.config.extension.TeamscalePluginExtension import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.* -import java.net.ConnectException -import java.net.SocketTimeoutException +import java.io.File +import java.io.IOException /** Handles report uploads to Teamscale. */ abstract class TeamscaleUploadTask : DefaultTask() { @@ -88,46 +89,63 @@ abstract class TeamscaleUploadTask : DefaultTask() { } private fun uploadReports(enabledReports: List) { - // We want to upload e.g. all JUnit test reports that go to the same partition - // as one commit, so we group them before uploading them - for ((key, reports) in enabledReports.groupBy { Triple(it.format, it.partition.get(), it.message.get()) }) { + // Group reports by format, partition, and message to upload similar reports together + val groupedReports = enabledReports.groupBy { + ReportGroupKey(it.format, it.partition.get(), it.message.get()) + } + + val teamscaleClient = server.toClient() + + groupedReports.forEach { (key, reports) -> val (format, partition, message) = key - val reportFiles = reports.flatMap { it.reportFiles.files }.filter { it.exists() }.distinct() - logger.info("Uploading ${reportFiles.size} ${format.name} report(s) to partition $partition...") + val reportFiles = getExistingReportFiles(reports) + if (reportFiles.isEmpty()) { - logger.info("Skipped empty upload!") - continue + logger.info("Skipped empty upload for ${format.name} reports to partition $partition.") + return@forEach } - logger.debug("Uploading $reportFiles") - - try { - // Prefer to upload to revision and fallback to branch timestamp - val commitDescriptorOrNull = if (revision != null) null else commitDescriptor!! - retry(3) { - val client = - TeamscaleClient(server.url, server.userName, server.userAccessToken, server.project) - client.uploadReports( - format, - reportFiles, - commitDescriptorOrNull, - revision, - repository, - partition, - message - ) - } - } catch (e: ConnectException) { - throw GradleException("Upload failed (${e.message})", e) - } catch (e: SocketTimeoutException) { - throw GradleException("Upload failed (${e.message})", e) + + logger.info("Uploading ${reportFiles.size} ${format.name} report(s) to partition $partition...") + logger.debug("Uploading {}", reportFiles) + + teamscaleClient.uploadReportFiles(format, reportFiles, partition, message) + } + } + + private data class ReportGroupKey( + val format: EReportFormat, + val partition: String, + val message: String + ) + + private fun getExistingReportFiles(reports: List) = + reports.flatMap { it.reportFiles.files } + .filter { it.exists() } + .distinct() + + private fun TeamscaleClient.uploadReportFiles( + format: EReportFormat, + reportFiles: List, + partition: String, + message: String + ) { + val commitDescriptorOrNull = if (revision == null) commitDescriptor else null + try { + retry(3) { + uploadReports( + format, reportFiles, commitDescriptorOrNull, + revision, repository, partition, message + ) } + } catch (e: IOException) { + throw GradleException("Upload failed (${e.message})", e) } } } /** * Retries the given block numOfRetries-times catching any thrown exceptions. - * If none of the retries succeeded the latest catched exception is rethrown. + * If none of the retries succeeded, the latest caught exception is rethrown. */ fun retry(numOfRetries: Int, block: () -> T): T { var throwable: Throwable? = null diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestImpacted.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestImpacted.kt index bfa96d081..e6abf890d 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestImpacted.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestImpacted.kt @@ -11,6 +11,7 @@ import org.gradle.api.tasks.* import org.gradle.api.tasks.options.Option import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.testing.junitplatform.JUnitPlatformOptions +import org.gradle.kotlin.dsl.withType import javax.inject.Inject /** Task which runs the impacted tests. */ @@ -203,7 +204,7 @@ abstract class TestImpacted @Inject constructor(objects: ObjectFactory) : Test() return project.configurations .getByName("testRuntimeClasspath") .allDependencies - .withType(ProjectDependency::class.java) + .withType() .map { it.dependencyProject } .flatMap { collectAllDependentJavaProjects(it, seenProjects) } .union(listOf(project)) @@ -219,10 +220,10 @@ abstract class TestImpacted @Inject constructor(objects: ObjectFactory) : Test() if (runImpacted) { assert(endCommit != null) { "When executing only impacted tests a branchName and timestamp must be specified!" } serverConfiguration.validate() - writeEngineProperty("server.url", serverConfiguration.url!!) - writeEngineProperty("server.project", serverConfiguration.project!!) - writeEngineProperty("server.userName", serverConfiguration.userName!!) - writeEngineProperty("server.userAccessToken", serverConfiguration.userAccessToken!!) + writeEngineProperty("server.url", serverConfiguration.url) + writeEngineProperty("server.project", serverConfiguration.project) + writeEngineProperty("server.userName", serverConfiguration.userName) + writeEngineProperty("server.userAccessToken", serverConfiguration.userAccessToken) } writeEngineProperty("partition", report.partition.get()) writeEngineProperty("endCommit", endCommit?.toString()) diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestwiseCoverageReportTask.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestwiseCoverageReportTask.kt index 1601b6495..4cacdeb79 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestwiseCoverageReportTask.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TestwiseCoverageReportTask.kt @@ -113,7 +113,7 @@ open class TestwiseCoverageReportTask : DefaultTask() { val report = TestwiseCoverageReportBuilder.createFrom( testDetails, - testwiseCoverage.tests, + testwiseCoverage.tests.values, testExecutions, reportConfig.partial ) @@ -149,7 +149,7 @@ fun Logger.wrapInILogger(): ILogger { override fun info(message: String) = logger.info(message) override fun warn(message: String) = logger.warn(message) override fun warn(message: String, throwable: Throwable?) = logger.warn(message, throwable) - override fun error(throwable: Throwable?) = logger.error("", throwable) + override fun error(throwable: Throwable) = logger.error("", throwable) override fun error(message: String, throwable: Throwable?) = logger.error(message, throwable) } } 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 283340366..29d1d30a7 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 @@ -10,68 +10,71 @@ import java.io.Serializable /** The commit object which holds the end commit for which we do Test Impact Analysis. */ class Commit : Serializable { - /** - * The branch to which the artifacts belong to. - * This field encapsulates the value set in the gradle config. - * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. - * It falls back to retrieving the values from the git repository, if not given manually. - */ - var branchName: String? = null - set(value) { - field = value?.trim() - } + /** + * The branch to which the artifacts belong to. + * This field encapsulates the value set in the gradle config. + * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. + * It falls back to retrieving the values from the git repository, if not given manually. + */ + var branchName: String? = null + set(value) { + field = value?.trim() + } - /** - * The timestamp of the commit that has been used to generate the artifacts. - * This field encapsulates the value set in the gradle config. - * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. - * It falls back to retrieving the values from the git repository, if not given manually. - */ - var timestamp: String? = null - set(value) { - field = value?.trim() - } + /** + * The timestamp of the commit that has been used to generate the artifacts. + * This field encapsulates the value set in the gradle config. + * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. + * It falls back to retrieving the values from the git repository, if not given manually. + */ + var timestamp: String? = null + set(value) { + field = value?.trim() + } - /** - * The revision of the commit that the artifacts should be uploaded to. - * This is e.g. the SHA1 hash of the commit in Git or the revision of the commit in SVN. - * This field encapsulates the value set in the gradle config. - * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. - * It falls back to retrieving the values from the git repository, if not given manually. - */ - var revision: String? = null - set(value) { - field = value?.trim() - } + /** + * The revision of the commit that the artifacts should be uploaded to. + * This is e.g. the SHA1 hash of the commit in Git or the revision of the commit in SVN. + * This field encapsulates the value set in the gradle config. + * Use [getOrResolveCommitDescriptor] to get a revision or branch and timestamp. + * It falls back to retrieving the values from the git repository, if not given manually. + */ + var revision: String? = null + set(value) { + field = value?.trim() + } - /** Read automatically in [getOrResolveCommitDescriptor] if [revision] is not set */ - private var resolvedRevision: String? = null - /** Read automatically in [getOrResolveCommitDescriptor] if [branchName] and [timestamp] are not set */ - private var resolvedCommit: CommitDescriptor? = null + /** Read automatically in [getOrResolveCommitDescriptor] if [revision] is not set */ + private var resolvedRevision: String? = null - /** - * Checks that a branch name and timestamp are set or can be retrieved from the projects git and - * stores them for later use. - */ - fun getOrResolveCommitDescriptor(project: Project): Pair { - try { - // If timestamp and branch are set manually, prefer to use them - if (branchName != null && timestamp != null) { - return Pair(CommitDescriptor(branchName, timestamp), null) - } - // If revision is set manually, use as 2nd option - if (revision != null) { - return Pair(null, revision) - } - // Otherwise fall back to getting the information from the git repository - if (resolvedRevision == null && resolvedCommit == null) { - val (commit, ref) = GitRepositoryHelper.getHeadCommitDescriptor(project.rootDir) - resolvedRevision = ref - resolvedCommit = commit - } - return Pair(resolvedCommit, resolvedRevision) - } catch (e: IOException) { - throw GradleException("Could not determine Teamscale upload commit", e) - } - } + /** Read automatically in [getOrResolveCommitDescriptor] if [branchName] and [timestamp] are not set */ + private var resolvedCommit: CommitDescriptor? = null + + /** + * Checks that a branch name and timestamp are set or can be retrieved from the projects git and + * stores them for later use. + */ + fun getOrResolveCommitDescriptor(project: Project): Pair { + try { + // If timestamp and branch are set manually, prefer to use them + branchName?.let { branch -> + timestamp?.let { time -> + return CommitDescriptor(branch, time) to null + } + } + // If revision is set manually, use as 2nd option + revision?.let { rev -> + return null to rev + } + // Otherwise fall back to getting the information from the git repository + if (resolvedRevision == null && resolvedCommit == null) { + val (commit, ref) = GitRepositoryHelper.getHeadCommitDescriptor(project.rootDir) + resolvedRevision = ref + resolvedCommit = commit + } + return resolvedCommit to resolvedRevision + } catch (e: IOException) { + throw GradleException("Could not determine Teamscale upload commit", e) + } + } } diff --git a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/ServerConfiguration.kt b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/ServerConfiguration.kt index 3f453db87..34e437df0 100644 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/ServerConfiguration.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/config/ServerConfiguration.kt @@ -1,26 +1,19 @@ package com.teamscale.config +import com.teamscale.client.TeamscaleClient import org.gradle.api.GradleException import java.io.Serializable -class ServerConfiguration : Serializable { - - /** The url of the Teamscale server. */ - var url: String? = null - - /** The project id for which artifacts should be uploaded. */ - var project: String? = null - - /** The user name of the Teamscale user. */ - var userName: String? = null - - /** The access token of the user. */ - var userAccessToken: String? = null - - override fun toString(): String { - return "ServerConfiguration(url=$url, project=$project, userName=$userName, userAccessToken=$userAccessToken)" - } - +data class ServerConfiguration( + /** The url of the Teamscale server. */ + var url: String? = null, + /** The project id for which artifacts should be uploaded. */ + var project: String? = null, + /** The username of the Teamscale user. */ + var userName: String? = null, + /** The access token of the user. */ + var userAccessToken: String? = null +) : Serializable { fun validate() { if (url.isNullOrBlank()) { throw GradleException("Teamscale server url must not be empty!") @@ -35,4 +28,10 @@ class ServerConfiguration : Serializable { throw GradleException("Teamscale user access token must not be empty!") } } + fun toClient() = TeamscaleClient( + url, + project ?: throw GradleException("Teamscale project name must not be null!"), + userName ?: throw GradleException("Teamscale user name must not be null!"), + userAccessToken ?: throw GradleException("Teamscale user access token must not be null!") + ) } diff --git a/teamscale-gradle-plugin/src/test/kotlin/com/teamscale/TeamscalePluginTest.kt b/teamscale-gradle-plugin/src/test/kotlin/com/teamscale/TeamscalePluginTest.kt index 7f586fd40..825feea1d 100644 --- a/teamscale-gradle-plugin/src/test/kotlin/com/teamscale/TeamscalePluginTest.kt +++ b/teamscale-gradle-plugin/src/test/kotlin/com/teamscale/TeamscalePluginTest.kt @@ -38,9 +38,9 @@ class TeamscalePluginTest { @BeforeEach fun startFakeTeamscaleServer() { - teamscaleMockServer = TeamscaleMockServer( - FAKE_TEAMSCALE_PORT - ).acceptingReportUploads().withImpactedTests("com/example/project/JUnit4Test/systemTest") + teamscaleMockServer = TeamscaleMockServer(FAKE_TEAMSCALE_PORT) + .acceptingReportUploads() + .withImpactedTests("com/example/project/JUnit4Test/systemTest") } @AfterEach @@ -249,7 +249,7 @@ class TeamscalePluginTest { private fun assertFullCoverage(source: String) { val testwiseCoverageReport = JsonUtils.deserialize(source, TestwiseCoverageReport::class.java) - assertThat(testwiseCoverageReport!!) + assertThat(testwiseCoverageReport) .hasPartial(false) .containsExecutionResult("com/example/project/IgnoredJUnit4Test/systemTest", ETestExecutionResult.SKIPPED) .containsExecutionResult("com/example/project/JUnit4Test/systemTest", ETestExecutionResult.PASSED) @@ -273,7 +273,7 @@ class TeamscalePluginTest { private fun assertPartialCoverage(source: String) { val testwiseCoverageReport = JsonUtils.deserialize(source, TestwiseCoverageReport::class.java) - assertThat(testwiseCoverageReport!!) + assertThat(testwiseCoverageReport) .hasPartial(true) .containsExecutionResult("com/example/project/JUnit4Test/systemTest", ETestExecutionResult.PASSED) .containsCoverage( diff --git a/teamscale-gradle-plugin/src/test/resources/calculator_groovy/build.gradle b/teamscale-gradle-plugin/src/test/resources/calculator_groovy/build.gradle index c98b8c4e7..c6d42395f 100644 --- a/teamscale-gradle-plugin/src/test/resources/calculator_groovy/build.gradle +++ b/teamscale-gradle-plugin/src/test/resources/calculator_groovy/build.gradle @@ -14,9 +14,9 @@ plugins { import com.teamscale.TestImpacted ext.junit4Version = '4.13.2' -ext.junitVintageVersion = '5.10.3' +ext.junitVintageVersion = '5.11.3' ext.junitPlatformVersion = '1.4.0' -ext.junitJupiterVersion = '5.10.3' +ext.junitJupiterVersion = '5.11.3' if (!project.hasProperty("withoutServerConfig")) { teamscale { diff --git a/teamscale-maven-plugin/.mvn/wrapper/maven-wrapper.properties b/teamscale-maven-plugin/.mvn/wrapper/maven-wrapper.properties index 06477ea70..23241dc39 100644 --- a/teamscale-maven-plugin/.mvn/wrapper/maven-wrapper.properties +++ b/teamscale-maven-plugin/.mvn/wrapper/maven-wrapper.properties @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -wrapperVersion=3.3.1 -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.8/apache-maven-3.9.8-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.1/maven-wrapper-3.3.1.jar +wrapperVersion=3.3.2 +distributionType=script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/teamscale-maven-plugin/build.gradle.kts b/teamscale-maven-plugin/build.gradle.kts index f4f2f2fbb..e24a2d461 100644 --- a/teamscale-maven-plugin/build.gradle.kts +++ b/teamscale-maven-plugin/build.gradle.kts @@ -1,4 +1,4 @@ -import org.codehaus.plexus.util.Os +import org.apache.tools.ant.taskdefs.condition.Os abstract class MavenExec : Exec() { @TaskAction diff --git a/teamscale-maven-plugin/mvnw b/teamscale-maven-plugin/mvnw index aa09908be..5e9618cac 100755 --- a/teamscale-maven-plugin/mvnw +++ b/teamscale-maven-plugin/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.3.1 +# Apache Maven Wrapper startup batch script, version 3.3.2 # # Required ENV vars: # ------------------ @@ -212,9 +212,9 @@ else log "Couldn't find $wrapperJarPath, downloading it ..." if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.3.1/maven-wrapper-3.3.1.jar" + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar" else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.1/maven-wrapper-3.3.1.jar" + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar" fi while IFS="=" read -r key value; do # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) diff --git a/teamscale-maven-plugin/mvnw.cmd b/teamscale-maven-plugin/mvnw.cmd index ef8be0400..1204076a9 100644 --- a/teamscale-maven-plugin/mvnw.cmd +++ b/teamscale-maven-plugin/mvnw.cmd @@ -18,7 +18,7 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.1 +@REM Apache Maven Wrapper startup batch script, version 3.3.2 @REM @REM Required ENV vars: @REM JAVA_HOME - location of a JDK home dir @@ -119,7 +119,7 @@ SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.1/maven-wrapper-3.3.1.jar" +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar" FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B @@ -133,7 +133,7 @@ if exist %WRAPPER_JAR% ( ) ) else ( if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.3.1/maven-wrapper-3.3.1.jar" + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar" ) if "%MVNW_VERBOSE%" == "true" ( echo Couldn't find %WRAPPER_JAR%, downloading it ... diff --git a/teamscale-maven-plugin/pom.xml b/teamscale-maven-plugin/pom.xml index e6c362466..9c2c8bbad 100644 --- a/teamscale-maven-plugin/pom.xml +++ b/teamscale-maven-plugin/pom.xml @@ -65,7 +65,7 @@ org.junit.jupiter junit-jupiter-engine - 5.10.3 + 5.11.3 test @@ -76,25 +76,25 @@ org.apache.maven maven-plugin-api - 3.9.8 + 3.9.9 provided org.apache.maven.plugin-tools maven-plugin-annotations - 3.13.1 + 3.15.1 provided org.apache.maven maven-core - 3.9.8 + 3.9.9 provided org.eclipse.jgit org.eclipse.jgit - 6.10.0.202406032230-r + 7.0.0.202409031743-r com.teamscale @@ -127,7 +127,7 @@ org.apache.maven.plugins maven-plugin-plugin - 3.13.1 + 3.15.1 generate-helpmojo @@ -140,7 +140,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.3.1 + 3.5.2 org.apache.maven.plugins @@ -158,7 +158,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.8.0 + 3.11.1 attach-javadocs @@ -184,7 +184,7 @@ org.apache.maven.plugins maven-gpg-plugin - 3.2.4 + 3.2.7 sign-artifacts @@ -206,7 +206,7 @@ org.apache.maven.plugins maven-deploy-plugin - 3.1.2 + 3.1.3 org.sonatype.plugins diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommit.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommit.java deleted file mode 100644 index 8767e68d7..000000000 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommit.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.teamscale.maven; - -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.lib.ObjectId; -import org.eclipse.jgit.lib.Ref; -import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.revwalk.RevCommit; -import org.eclipse.jgit.revwalk.RevWalk; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Represents a single commit in a Git repository. - */ -public class GitCommit { - - /** The SHA1 of this commit. */ - public final String sha1; - /** The timestamp of this commit (Unix epoch in milliseconds). */ - public final long timestamp; - /** The branch of this commit. */ - public final String branch; - - private GitCommit(String sha1, long timestamp, String branch) { - this.sha1 = sha1; - this.timestamp = timestamp; - this.branch = branch; - } - - /** - * Determines the current HEAD commit in the Git repository located in the or above the given search directory. - * - * @throws IOException if reading from the Git repository fails or the current directory is not a Git repository. - */ - public static GitCommit getGitHeadCommitDescriptor(Path searchDirectory) throws IOException { - Path gitDirectory = findGitBaseDirectory(searchDirectory); - Git git = Git.open(gitDirectory.toFile()); - Repository repository = git.getRepository(); - String branch = repository.getBranch(); - RevCommit commit = getCommit(repository, branch); - long commitTimeSeconds = commit.getCommitTime(); - String ref = repository.getRefDatabase().findRef("HEAD").getObjectId().getName(); - return new GitCommit(ref, commitTimeSeconds * 1000L, branch); - } - - /** - * Traverses the directory tree upwards until it finds a .git directory. Returns null if no .git directory is - * found. - */ - private static Path findGitBaseDirectory(Path searchDirectory) { - while (searchDirectory != null) { - if (Files.exists(searchDirectory.resolve(".git"))) { - return searchDirectory; - } - searchDirectory = searchDirectory.getParent(); - } - return null; - } - - private static RevCommit getCommit(Repository repository, String revisionBranchOrTag) throws IOException { - try (RevWalk revWalk = new RevWalk(repository)) { - Ref head = repository.getRefDatabase().findRef(revisionBranchOrTag); - if (head != null) { - return revWalk.parseCommit(head.getLeaf().getObjectId()); - } else { - return revWalk.parseCommit(ObjectId.fromString(revisionBranchOrTag)); - } - } - } -} diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommitUtils.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommitUtils.java new file mode 100644 index 000000000..092ba3a49 --- /dev/null +++ b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/GitCommitUtils.java @@ -0,0 +1,45 @@ +package com.teamscale.maven; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.Repository; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Utils for working with a Git repository. + */ +public class GitCommitUtils { + + /** + * Determines the current HEAD commit in the Git repository located in the or above the given search directory. + * + * @throws IOException if reading from the Git repository fails or the current directory is not a Git repository. + */ + public static String getGitHeadRevision(Path searchDirectory) throws IOException { + Path gitDirectory = findGitBaseDirectory(searchDirectory); + if (gitDirectory == null) { + throw new IOException("Could not find git directory in " + searchDirectory); + } + Repository repository; + try (Git git = Git.open(gitDirectory.toFile())) { + repository = git.getRepository(); + return repository.getRefDatabase().findRef("HEAD").getObjectId().getName(); + } + } + + /** + * Traverses the directory tree upwards until it finds a .git directory. Returns null if no .git directory is + * found. + */ + private static Path findGitBaseDirectory(Path searchDirectory) { + while (searchDirectory != null) { + if (Files.exists(searchDirectory.resolve(".git"))) { + return searchDirectory; + } + searchDirectory = searchDirectory.getParent(); + } + return null; + } +} diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/TeamscaleMojoBase.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/TeamscaleMojoBase.java index 973f5f047..1996d1a3b 100644 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/TeamscaleMojoBase.java +++ b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/TeamscaleMojoBase.java @@ -1,8 +1,5 @@ package com.teamscale.maven; -import java.io.IOException; -import java.nio.file.Path; - import org.apache.commons.lang3.StringUtils; import org.apache.maven.execution.MavenSession; import org.apache.maven.model.Plugin; @@ -14,9 +11,12 @@ import org.apache.maven.project.MavenProject; import org.codehaus.plexus.util.xml.Xpp3Dom; +import java.io.IOException; +import java.nio.file.Path; + /** - * A base class for all Teamscale related maven Mojos. - * Offers basic attributes and functionality related to Teamscale and Maven. + * A base class for all Teamscale related maven Mojos. Offers basic attributes and functionality related to Teamscale + * and Maven. */ public abstract class TeamscaleMojoBase extends AbstractMojo { @@ -40,8 +40,8 @@ public abstract class TeamscaleMojoBase extends AbstractMojo { public String username; /** - * Teamscale access token of the {@link #username}. Can also be specified via the Maven property {@code - * teamscale.accessToken}. + * Teamscale access token of the {@link #username}. Can also be specified via the Maven property + * {@code teamscale.accessToken}. */ @Parameter(property = "teamscale.accessToken") public String accessToken; @@ -50,21 +50,25 @@ public abstract class TeamscaleMojoBase extends AbstractMojo { * You can optionally use this property to override the code commit to which the coverage will be uploaded. Format: * {@code BRANCH:UNIX_EPOCH_TIMESTAMP_IN_MILLISECONDS} *

- * If no end commit is manually specified, the plugin will try to determine the currently checked out Git commit. + * If no commit and revision is manually specified, the plugin will try to determine the currently checked-out Git + * commit. You should specify either commit or revision, not both. If both are specified, a warning is logged and + * the revision takes precedence. */ @Parameter(property = "teamscale.commit") public String commit; /** - * You can optionally use this property to override the revision to which the coverage will be uploaded. - * If no revision is manually specified, the plugin will try to determine the current git revision. + * You can optionally use this property to override the revision to which the coverage will be uploaded. If no + * commit and revision is manually specified, the plugin will try to determine the current git revision. You should + * specify either commit or revision, not both. If both are specified, a warning is logged and the revision takes + * precedence. */ @Parameter(property = "teamscale.revision") public String revision; /** - * The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. - * Null or empty will lead to a lookup in all repositories in the Teamscale project. + * The repository id in your Teamscale project which Teamscale should use to look up the revision, if given. Null or + * empty will lead to a lookup in all repositories in the Teamscale project. */ @Parameter(property = "teamscale.repository") public String repository; @@ -91,25 +95,6 @@ public abstract class TeamscaleMojoBase extends AbstractMojo { */ protected String resolvedRevision; - /** - * Sets the resolvedCommit and resolvedRevision, if not provided, via the GitCommit class - * @see GitCommit - */ - protected void resolveCommit() throws MojoFailureException { - if (StringUtils.isNotBlank(commit)) { - resolvedCommit = commit; - return; - } - Path basedir = session.getCurrentProject().getBasedir().toPath(); - try { - GitCommit commit = GitCommit.getGitHeadCommitDescriptor(basedir); - resolvedCommit = commit.branch + ":" + commit.timestamp; - } catch (IOException e) { - throw new MojoFailureException("There is no configured in the pom.xml" + - " and it was not possible to determine the checked out commit in " + basedir + " from Git", e); - } - } - @Override public void execute() throws MojoExecutionException, MojoFailureException { if (StringUtils.isNotEmpty(revision) && StringUtils.isNotBlank(commit)) { @@ -119,29 +104,34 @@ public void execute() throws MojoExecutionException, MojoFailureException { } /** - * Sets the resolvedRevision, if not provided, via the GitCommit class + * Sets the resolvedRevision or resolvedCommit. If not provided, try to determine the + * revision via the GitCommit class. * - * @see GitCommit + * @see GitCommitUtils */ - protected void resolveRevision() throws MojoFailureException { + protected void resolveCommitOrRevision() throws MojoFailureException { if (StringUtils.isNotBlank(revision)) { resolvedRevision = revision; - } else { - Path basedir = session.getCurrentProject().getBasedir().toPath(); - try { - GitCommit commit = GitCommit.getGitHeadCommitDescriptor(basedir); - resolvedRevision = commit.sha1; - } catch (IOException e) { - throw new MojoFailureException("There is no configured in the pom.xml" + - " and it was not possible to determine the current revision in " + basedir + " from Git", e); - } + return; + } + if (StringUtils.isNotBlank(commit)) { + resolvedCommit = commit; + return; + } + Path basedir = session.getCurrentProject().getBasedir().toPath(); + try { + resolvedRevision = GitCommitUtils.getGitHeadRevision(basedir); + } catch (IOException e) { + throw new MojoFailureException("There is no or configured in the pom.xml" + + " and it was not possible to determine the current revision in " + basedir + " from Git", e); } } /** * Retrieves the configuration of a goal execution for the given plugin + * * @param pluginArtifact The id of the plugin - * @param pluginGoal The name of the goal + * @param pluginGoal The name of the goal * @return The configuration DOM if present, otherwise null */ protected Xpp3Dom getExecutionConfigurationDom(MavenProject project, String pluginArtifact, String pluginGoal) { diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaCoverageConvertMojo.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaCoverageConvertMojo.java index 294699b66..81aa2c5a5 100644 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaCoverageConvertMojo.java +++ b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaCoverageConvertMojo.java @@ -89,7 +89,7 @@ public void execute() throws MojoFailureException { classFileDirectories = getClassDirectoriesOrZips(projectBuildDir); findSubprojectReportAndClassDirectories(reportFileDirectories, classFileDirectories); } catch (IOException | AgentOptionParseException e) { - logger.error("Could not create testwise report generator. Aborting."); + logger.error("Could not create testwise report generator. Aborting.", e); throw new MojoFailureException(e); } logger.info("Generating the testwise coverage report"); @@ -129,10 +129,9 @@ private TestInfoFactory createTestInfoFactory(List reportFiles) throws Moj logger.info("Writing report with " + testDetails.size() + " Details/" + testExecutions.size() + " Results"); return new TestInfoFactory(testDetails, testExecutions); } catch (IOException e) { - logger.error("Could not read test details from reports. Aborting."); + logger.error("Could not read test details from reports. Aborting.", e); throw new MojoFailureException(e); } - } private JaCoCoTestwiseReportGenerator createJaCoCoTestwiseReportGenerator(List classFiles) { diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java index 8f27150c9..23be7bbf0 100644 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java +++ b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java @@ -1,16 +1,6 @@ package com.teamscale.maven.tia; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.ServerSocket; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.Map; -import java.util.Properties; - +import com.teamscale.maven.TeamscaleMojoBase; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.util.Strings; @@ -25,7 +15,16 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import com.teamscale.maven.TeamscaleMojoBase; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; /** * Base class for TIA Mojos. Provides all necessary functionality but can be subclassed to change the partition. @@ -189,8 +188,7 @@ public void execute() throws MojoFailureException, MojoExecutionException { targetDirectory = Paths.get(projectBuildDir, "tia").toAbsolutePath(); createTargetDirectory(); - resolveCommit(); - resolveRevision(); + resolveCommitOrRevision(); setTiaProperties(); @@ -206,10 +204,10 @@ private void setTiaProperties() { setTiaProperty("server.userName", username); setTiaProperty("server.userAccessToken", accessToken); - if (StringUtils.isNotEmpty(commit)) { - setTiaProperty("endCommit", resolvedCommit); - } else { + if (StringUtils.isNotEmpty(resolvedRevision)) { setTiaProperty("endRevision", resolvedRevision); + } else { + setTiaProperty("endCommit", resolvedCommit); } if (StringUtils.isNotEmpty(baselineRevision)) { @@ -418,9 +416,7 @@ private String createAgentConfig(Path loggingConfigPath, Path agentOutputDirecto config += "\nteamscale-repository=" + repository; } - // "commit" (in contrast to "resolvedCommit") is only set via the config option in the pom. - // If the user sets it, prefer it over the revision. If not, prefer the revision - if (StringUtils.isNotEmpty(resolvedRevision) && StringUtils.isEmpty(commit)) { + if (StringUtils.isNotEmpty(resolvedRevision)) { config += "\nteamscale-revision=" + resolvedRevision; } else { config += "\nteamscale-commit=" + resolvedCommit; diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/upload/CoverageUploadMojo.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/upload/CoverageUploadMojo.java index 4ac1828ed..915e411a5 100644 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/upload/CoverageUploadMojo.java +++ b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/upload/CoverageUploadMojo.java @@ -1,15 +1,8 @@ package com.teamscale.maven.upload; -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - +import com.google.common.base.Strings; +import com.teamscale.maven.TeamscaleMojoBase; +import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugin.MojoFailureException; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; @@ -17,14 +10,20 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.apache.maven.project.MavenProject; import org.codehaus.plexus.util.xml.Xpp3Dom; - -import com.google.common.base.Strings; -import com.teamscale.maven.TeamscaleMojoBase; - import shadow.com.teamscale.client.CommitDescriptor; import shadow.com.teamscale.client.EReportFormat; import shadow.com.teamscale.client.TeamscaleClient; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + /** * Run this goal after the Jacoco report generation to upload them to a * configured Teamscale instance. The configuration can be specified in the root @@ -116,7 +115,8 @@ public class CoverageUploadMojo extends TeamscaleMojoBase { private TeamscaleClient teamscaleClient; @Override - public void execute() throws MojoFailureException { + public void execute() throws MojoFailureException, MojoExecutionException { + super.execute(); if (skip) { getLog().debug("Skipping since skip is set to true"); return; @@ -127,8 +127,7 @@ public void execute() throws MojoFailureException { } teamscaleClient = new TeamscaleClient(teamscaleUrl, username, accessToken, projectId); getLog().debug("Resolving end commit"); - resolveCommit(); - resolveRevision(); + resolveCommitOrRevision(); getLog().debug("Parsing Jacoco plugin configurations"); parseJacocoConfiguration(); try { diff --git a/tia-client/build.gradle.kts b/tia-client/build.gradle.kts index c0fa57733..d49504163 100644 --- a/tia-client/build.gradle.kts +++ b/tia-client/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + kotlin("jvm") `java-library` com.teamscale.`java-convention` com.teamscale.coverage diff --git a/tia-client/src/main/java/com/teamscale/tia.client/AgentCommunicationUtils.java b/tia-client/src/main/java/com/teamscale/tia.client/AgentCommunicationUtils.java deleted file mode 100644 index c1ad5581b..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/AgentCommunicationUtils.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.teamscale.tia.client; - -import com.teamscale.client.HttpUtils; -import retrofit2.Call; -import retrofit2.Response; - -import java.io.IOException; -import java.util.function.Supplier; - -/** - * Utilities for performing requests to the agent. - */ -class AgentCommunicationUtils { - - /** - * Performs the given request and handles common errors (e.g. network failures, internal exceptions in the agent). - * In case of network problems, retries the request once. - */ - static T handleRequestError(Supplier> requestFactory, String errorMessage) - throws AgentHttpRequestFailedException { - return handleRequestError(requestFactory, errorMessage, true); - } - - private static T handleRequestError(Supplier> requestFactory, String errorMessage, boolean retryOnce) - throws AgentHttpRequestFailedException { - - try { - Response response = requestFactory.get().execute(); - if (response.isSuccessful()) { - return response.body(); - } - - String bodyString = HttpUtils.getErrorBodyStringSafe(response); - throw new AgentHttpRequestFailedException( - errorMessage + ". The agent responded with HTTP status " + response.code() + " " + response - .message() + ". Response body: " + bodyString); - } catch (IOException e) { - if (!retryOnce) { - throw new AgentHttpRequestFailedException( - errorMessage + ". I already retried this request and it failed twice (see the suppressed" + - " exception for details of the first failure). This is probably a network problem" + - " that you should address.", e); - } - - // retry once on network problems - try { - return handleRequestError(requestFactory, errorMessage, false); - } catch (Throwable t) { - t.addSuppressed(e); - throw t; - } - } - } - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/AgentHttpRequestFailedException.java b/tia-client/src/main/java/com/teamscale/tia.client/AgentHttpRequestFailedException.java deleted file mode 100644 index b4a3a21c3..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/AgentHttpRequestFailedException.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.teamscale.tia.client; - -/** - * Thrown if communicating with the agent via HTTP fails. The underlying reason can be either a network problem or an - * internal error in the agent. Users of this library should report these exceptions appropriately so the underlying - * problems can be addressed. - */ -public class AgentHttpRequestFailedException extends Exception { - - public AgentHttpRequestFailedException(String message) { - super(message); - } - - public AgentHttpRequestFailedException(String message, Throwable cause) { - super(message, cause); - } - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/CommandLineInterface.java b/tia-client/src/main/java/com/teamscale/tia.client/CommandLineInterface.java deleted file mode 100644 index d43209f7f..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/CommandLineInterface.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.teamscale.tia.client; - -import static java.util.stream.Collectors.joining; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.client.StringUtils; -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.report.testwise.model.TestExecution; - -import okhttp3.HttpUrl; - -/** - * Simple command-line interface to expose the {@link TiaAgent} to non-Java test runners. - */ -public class CommandLineInterface { - - private static class InvalidCommandLineException extends RuntimeException { - public InvalidCommandLineException(String message) { - super(message); - } - } - - private final List arguments; - private final String command; - private final ITestwiseCoverageAgentApi api; - - public CommandLineInterface(String[] arguments) { - this.arguments = new ArrayList<>(Arrays.asList(arguments)); - if (arguments.length < 2) { - throw new InvalidCommandLineException( - "You must provide at least two arguments: the agent's URL and the command to execute"); - } - - HttpUrl url = HttpUrl.parse(this.arguments.remove(0)); - api = ITestwiseCoverageAgentApi.createService(url); - - command = this.arguments.remove(0); - } - - /** Entry point. */ - public static void main(String[] arguments) throws Exception { - new CommandLineInterface(arguments).runCommand(); - } - - private void runCommand() throws Exception { - switch (command) { - case "startTestRun": - startTestRun(); - break; - case "startTest": - startTest(); - break; - case "endTest": - endTest(); - break; - case "endTestRun": - endTestRun(); - break; - default: - throw new InvalidCommandLineException( - "Unknown command '" + command + "'. Should be one of startTestRun, startTest, endTest," + - " endTestRun"); - } - } - - private void endTestRun() throws Exception { - boolean partial; - if (arguments.size() == 1) { - partial = Boolean.parseBoolean(arguments.remove(0)); - } else { - partial = false; - } - AgentCommunicationUtils.handleRequestError(() -> api.testRunFinished(partial), - "Failed to create a coverage report and upload it to Teamscale. The coverage is most likely lost"); - } - - private void endTest() throws Exception { - if (arguments.size() < 2) { - throw new InvalidCommandLineException( - "You must provide the uniform path of the test that is about to be started" + - " as the first argument of the endTest command and the test result as the second."); - } - String uniformPath = arguments.remove(0); - ETestExecutionResult result = ETestExecutionResult.valueOf(arguments.remove(0).toUpperCase()); - - String message = readStdin(); - - // the agent already records test duration, so we can simply provide a dummy value here - TestExecution execution = new TestExecution(uniformPath, 0L, result, message); - AgentCommunicationUtils.handleRequestError( - () -> api.testFinished(UrlUtils.percentEncode(uniformPath), execution), - "Failed to end coverage recording for test case " + uniformPath + - ". Coverage for that test case is most likely lost."); - } - - private void startTest() throws Exception { - if (arguments.size() < 1) { - throw new InvalidCommandLineException( - "You must provide the uniform path of the test that is about to be started" + - " as the first argument of the startTest command"); - } - String uniformPath = arguments.remove(0); - AgentCommunicationUtils.handleRequestError(() -> api.testStarted(UrlUtils.percentEncode(uniformPath)), - "Failed to start coverage recording for test case " + uniformPath + - ". Coverage for that test case is lost."); - } - - private void startTestRun() throws Exception { - boolean includeNonImpacted = parseAndRemoveBooleanSwitch("include-non-impacted"); - Long baseline = parseAndRemoveLongParameter("baseline"); - String baselineRevision = parseAndRemoveStringParameter("baseline-revision"); - List availableTests = parseAvailableTestsFromStdin(); - - List clusters = AgentCommunicationUtils.handleRequestError(() -> - api.testRunStarted(includeNonImpacted, baseline, baselineRevision, availableTests), "Failed to start the test run"); - System.out.println(JsonUtils.serialize(clusters)); - } - - private List parseAvailableTestsFromStdin() throws java.io.IOException { - String json = readStdin(); - List availableTests = Collections.emptyList(); - if (!StringUtils.isEmpty(json)) { - availableTests = JsonUtils.deserializeList(json, ClusteredTestDetails.class); - } - return availableTests; - } - - private String readStdin() { - return new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)).lines() - .collect(joining("\n")); - } - - private Long parseAndRemoveLongParameter(String name) { - for (int i = 0; i < arguments.size(); i++) { - if (arguments.get(i).startsWith("--" + name + "=")) { - String argument = arguments.remove(i); - return Long.parseLong(argument.substring(name.length() + 3)); - } - } - return null; - } - - private boolean parseAndRemoveBooleanSwitch(String name) { - for (int i = 0; i < arguments.size(); i++) { - if (arguments.get(i).equals("--" + name)) { - arguments.remove(i); - return true; - } - } - return false; - } - - private String parseAndRemoveStringParameter(String name) { - for (int i = 0; i < arguments.size(); i++) { - if (arguments.get(i).startsWith("--" + name + "=")) { - String argument = arguments.remove(i); - return argument.substring(name.length() + 3); - } - } - return null; - } - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/ITestwiseCoverageAgentApi.java b/tia-client/src/main/java/com/teamscale/tia.client/ITestwiseCoverageAgentApi.java deleted file mode 100644 index c62000d30..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/ITestwiseCoverageAgentApi.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.teamscale.tia.client; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.PrioritizableTestCluster; -import com.teamscale.report.testwise.model.TestExecution; - -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Retrofit; -import retrofit2.converter.jackson.JacksonConverterFactory; -import retrofit2.http.Body; -import retrofit2.http.POST; -import retrofit2.http.PUT; -import retrofit2.http.Path; -import retrofit2.http.Query; - -/** {@link Retrofit} API specification for the JaCoCo agent in test-wise coverage mode. */ -public interface ITestwiseCoverageAgentApi { - - /** Set the partition name as shown in Teamscale. */ - @PUT("partition") - Call setPartition(@Body String partition); - - /** Set the revision as shown in Teamscale. */ - @PUT("revision") - Call setRevision(@Body String partition); - - /** Set the upload commit as shown in Teamscale. */ - @PUT("commit") - Call setCommit(@Body String commit); - - /** Set the commit message with which the upload is shown in Teamscale. */ - @PUT("message") - Call setMessage(@Body String message); - - /** Test start. */ - @POST("test/start/{testUniformPath}") - Call testStarted(@Path(value = "testUniformPath", encoded = true) String testUniformPath); - - /** Test finished. */ - @POST("test/end/{testUniformPath}") - Call testFinished( - @Path(value = "testUniformPath", encoded = true) String testUniformPath - ); - - /** Test finished. */ - @POST("test/end/{testUniformPath}") - Call testFinished( - @Path(value = "testUniformPath", encoded = true) String testUniformPath, - @Body TestExecution testExecution - ); - - /** - * Test run started. Returns a single dummy cluster of TIA-selected and -prioritized tests - * that Teamscale currently knows about. - */ - @POST("testrun/start") - Call> testRunStarted( - @Query("include-non-impacted") boolean includeNonImpacted, - @Query("baseline") Long baseline, - @Query("baseline-revision") String baselineRevision - ); - - /** - * Test run started. Returns the list of TIA-selected and -prioritized test clusters to execute. - */ - @POST("testrun/start") - Call> testRunStarted( - @Query("include-non-impacted") boolean includeNonImpacted, - @Query("baseline") Long baseline, - @Query("baseline-revision") String baselineRevision, - @Body List availableTests - ); - - /** - * Test run finished. Generate test-wise coverage report and upload to Teamscale. - * - * @param partial Whether the test recording only contains a subset of the available tests. - */ - @POST("testrun/end") - Call testRunFinished(@Query("partial") Boolean partial); - - /** - * Generates a {@link Retrofit} instance for this service, which uses basic auth to authenticate against the server - * and which sets the Accept header to JSON. - */ - static ITestwiseCoverageAgentApi createService(HttpUrl baseUrl) { - OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder(); - httpClientBuilder.connectTimeout(60, TimeUnit.SECONDS); - httpClientBuilder.readTimeout(120, TimeUnit.SECONDS); - httpClientBuilder.writeTimeout(60, TimeUnit.SECONDS); - Retrofit retrofit = new Retrofit.Builder() - .client(httpClientBuilder.build()) // - .baseUrl(baseUrl) // - .addConverterFactory(JacksonConverterFactory.create()) // - .build(); - return retrofit.create(ITestwiseCoverageAgentApi.class); - } -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/RunningTest.java b/tia-client/src/main/java/com/teamscale/tia.client/RunningTest.java deleted file mode 100644 index fe991f39a..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/RunningTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.teamscale.tia.client; - -import com.teamscale.client.JsonUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.TestInfo; -import okhttp3.ResponseBody; - -import java.io.IOException; - -/** - * Represents a single test that is currently being executed by the caller of this library. Use - * {@link #endTest(TestRun.TestResultWithMessage)} or {@link #endTestAndRetrieveCoverage(TestRun.TestResultWithMessage)} - * to signal that executing the test case has finished and test-wise coverage for this test should be stored. - */ -@SuppressWarnings("unused") -public class RunningTest { - - private static class AgentConfigurationMismatch extends RuntimeException { - private AgentConfigurationMismatch(String message) { - super(message); - } - } - - private final String uniformPath; - private final ITestwiseCoverageAgentApi api; - - public RunningTest(String uniformPath, ITestwiseCoverageAgentApi api) { - this.uniformPath = uniformPath; - this.api = api; - } - - /** - * Signals to the agent that the test runner has finished executing this test and the result of the test run. - * - * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This - * method already retries the request once, so this is likely a terminal - * failure. The caller should record this problem appropriately. Coverage - * for subsequent test cases could, however, potentially still be recorded. - * Thus, the caller should continue with test execution and continue - * informing the coverage agent about further test start and end events. - */ - public void endTest(TestRun.TestResultWithMessage result) throws AgentHttpRequestFailedException { - // the agent already records test duration, so we can simply provide a dummy value here - TestExecution execution = new TestExecution(uniformPath, 0L, result.result, - result.message); - ResponseBody body = AgentCommunicationUtils - .handleRequestError(() -> api.testFinished(UrlUtils.percentEncode(uniformPath), execution), - "Failed to end coverage recording for test case " + uniformPath + - ". Coverage for that test case is most likely lost."); - - if (!StringUtils.isBlank(readBodyStringNullSafe(body))) { - throw new AgentConfigurationMismatch("The agent seems to be configured to return test coverage via" + - " HTTP to the tia-client (agent option `tia-mode=http`) but you did not instruct the" + - " tia-client to handle this. Please either reconfigure the agent or call" + - " #endTestAndRetrieveCoverage() instead of this method and handle the returned coverage." + - " As it is currently configured, the agent will not store or process the recorded coverage" + - " in any way other than sending it to the tia-client via HTTP so it is lost permanently."); - } - } - - private String readBodyStringNullSafe(ResponseBody body) throws AgentHttpRequestFailedException { - if (body == null) { - return null; - } - - try { - return body.string(); - } catch (IOException e) { - throw new AgentHttpRequestFailedException("Unable to read agent HTTP response body string", e); - } - } - - /** - * Signals to the agent that the test runner has finished executing this test and the result of the test run. It - * will also parse the testwise coverage data returned by the agent for this test and return it so it can be - * manually processed by you. The agent will not store or otherwise process this coverage, so be sure to do so - * yourself. - *

- * This method assumes that the agent is configured to return each test's coverage data via HTTP. It will receive - * and parse the data. If the agent is configured differently, this method will throw a terminal - * {@link RuntimeException}. In this case, you must reconfigure the agent with the `tia-mode=http` option enabled. - * - * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This - * method already retries the request once, so this is likely a terminal - * failure. The recorded coverage is likely lost. The caller should log this - * problem appropriately. - */ - public TestInfo endTestAndRetrieveCoverage( - TestRun.TestResultWithMessage result) throws AgentHttpRequestFailedException { - // the agent already records test duration, so we can simply provide a dummy value here - TestExecution execution = new TestExecution(uniformPath, 0L, result.result, - result.message); - ResponseBody body = AgentCommunicationUtils.handleRequestError( - () -> api.testFinished(UrlUtils.percentEncode(uniformPath), execution), - "Failed to end coverage recording for test case " + uniformPath + - ". Coverage for that test case is most likely lost."); - - String json = readBodyStringNullSafe(body); - if (StringUtils.isBlank(json)) { - throw new AgentConfigurationMismatch("You asked the tia-client to retrieve this test's coverage via HTTP" + - " but the agent is not configured for this. Please reconfigure the agent to use `tia-mode=http`."); - } - - try { - return JsonUtils.deserialize(json, TestInfo.class); - } catch (IOException e) { - throw new AgentHttpRequestFailedException("Unable to parse the JSON returned by the agent. Maybe you have" + - " a version mismatch between the tia-client and the agent?. Json returned by the agent: `" + json + - "`", e); - } - } - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/TestRun.java b/tia-client/src/main/java/com/teamscale/tia.client/TestRun.java deleted file mode 100644 index 48a122922..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/TestRun.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.teamscale.tia.client; - -import com.teamscale.report.testwise.model.ETestExecutionResult; - -/** - * Use this class to report test start and end events and upload testwise coverage to Teamscale. - *

- * After having run all tests, call {@link #endTestRun(boolean)} to create a testwise coverage report and upload it to - * Teamscale. This requires that you configured the agent to upload coverage to Teamscale - * (`tia-mode=teamscale-upload`). - */ -public class TestRun { - - private final ITestwiseCoverageAgentApi api; - - TestRun(ITestwiseCoverageAgentApi api) { - this.api = api; - } - - /** - * Represents the result of running a single test. - */ - public static class TestResultWithMessage { - - /** Whether the test succeeded or failed. */ - public final ETestExecutionResult result; - - /** An optional message, e.g. a stack trace in case of test failures. */ - public final String message; - - public TestResultWithMessage(ETestExecutionResult result, String message) { - this.result = result; - this.message = message; - } - } - - /** - * Informs the testwise coverage agent that a new test is about to start. - * - * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. In - * this case, the agent probably doesn't know that this test case was - * started, so its coverage is lost. This method already retries the request - * once, so this is likely a terminal failure. The caller should log this - * problem appropriately. Coverage for subsequent test cases could, however, - * potentially still be recorded. Thus, the caller should continue with test - * execution and continue informing the coverage agent about further test - * start and end events. - */ - public RunningTest startTest(String uniformPath) throws AgentHttpRequestFailedException { - AgentCommunicationUtils.handleRequestError(() -> api.testStarted(UrlUtils.percentEncode(uniformPath)), - "Failed to start coverage recording for test case " + uniformPath); - return new RunningTest(uniformPath, api); - } - - /** - * Informs the testwise coverage agent that the caller has finished running all tests and should upload coverage to - * Teamscale. Only call this if you configured the agent to upload coverage to Teamscale - * (`tia-mode=teamscale-upload`). Otherwise, this method will throw an exception. - * - * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This - * method already retries the request once, so this is likely a terminal - * failure. The recorded coverage is likely lost. The caller should log this - * problem appropriately. - */ - public void endTestRun(boolean partial) throws AgentHttpRequestFailedException { - AgentCommunicationUtils.handleRequestError(() -> api.testRunFinished(partial), - "Failed to create a coverage report and upload it to Teamscale. The coverage is most likely lost"); - } - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithClusteredSuggestions.java b/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithClusteredSuggestions.java deleted file mode 100644 index 31e189432..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithClusteredSuggestions.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.teamscale.tia.client; - -import com.teamscale.client.PrioritizableTestCluster; - -import java.util.List; - -/** - * Represents a run of prioritized and selected test clusters as reported by the TIA. Use this class to report test - * start and end events and upload testwise coverage to Teamscale. - *

- * The caller of this class should retrieve the test clusters to execute from {@link #getPrioritizedClusters()}, run - * them (in the given order if possible) and report test start and end events via {@link #startTest(String)} )}. - *

- * After having run all tests, call {@link #endTestRun(boolean)} to create a testwise coverage report and upload it to - * Teamscale. - */ -public class TestRunWithClusteredSuggestions extends TestRun { - - private final List prioritizedClusters; - - TestRunWithClusteredSuggestions(ITestwiseCoverageAgentApi api, - List prioritizedClusters) { - super(api); - this.prioritizedClusters = prioritizedClusters; - } - - public List getPrioritizedClusters() { - return prioritizedClusters; - } -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithFlatSuggestions.java b/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithFlatSuggestions.java deleted file mode 100644 index 3e57845d9..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/TestRunWithFlatSuggestions.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.teamscale.tia.client; - -import com.teamscale.client.PrioritizableTest; - -import java.util.List; - -/** - * Represents a run of a flat list of prioritized and selected tests as reported by the TIA. Use this class to report - * test start and end events and upload testwise coverage to Teamscale. - *

- * The caller of this class should retrieve the tests to execute from {@link #getPrioritizedTests()}, run them (in the - * given order if possible) and report test start and end events via {@link #startTest(String)} )}. - *

- * After having run all tests, call {@link #endTestRun(boolean)} to create a testwise coverage report and upload it to - * Teamscale. - */ -public class TestRunWithFlatSuggestions extends TestRun { - - private final List prioritizedTests; - - TestRunWithFlatSuggestions(ITestwiseCoverageAgentApi api, - List prioritizedTests) { - super(api); - this.prioritizedTests = prioritizedTests; - } - - public List getPrioritizedTests() { - return prioritizedTests; - } -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/TiaAgent.java b/tia-client/src/main/java/com/teamscale/tia.client/TiaAgent.java deleted file mode 100644 index ac165df1d..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/TiaAgent.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.teamscale.tia.client; - -import java.time.Instant; -import java.util.List; - -import com.teamscale.client.ClusteredTestDetails; -import com.teamscale.client.PrioritizableTestCluster; - -import okhttp3.HttpUrl; - -/** - * Communicates with one Teamscale JaCoCo agent in testwise coverage mode to facilitate the Test Impact analysis. - *

- * Use this class to retrieve impacted tests from Teamscale, start a {@link TestRun} based on these selected and - * prioritized tests and upload test-wise coverage after having executed the tests. - *

- * The caller of this class is responsible for actually executing the tests. - * - *

Available Tests

- * This API allows you to pass a list of available tests to start a test run. This list is used for multiple purposes: - * - *
    - *
  • To determine if any tests changed, were added or removed: Teamscale will not suggest deleted tests and - * will always suggest changed or added tests. - *
  • To cluster tests: The list of available tests includes information about test clusters, which are logical - * groups of tests. Teamscale will only prioritize tests against each other if they are in the same cluster. - *
- */ -public class TiaAgent { - - private final boolean includeNonImpactedTests; - private final ITestwiseCoverageAgentApi api; - - /** - * @param includeNonImpactedTests if this is true, only prioritization is performed, no test selection. - * @param url URL under which the agent is reachable. - */ - public TiaAgent(boolean includeNonImpactedTests, HttpUrl url) { - this.includeNonImpactedTests = includeNonImpactedTests; - api = ITestwiseCoverageAgentApi.createService(url); - } - - /** - * Starts a test run but does not ask Teamscale to prioritize and select any test cases. Use this when you only want - * to record test-wise coverage and don't care about TIA's test selection and prioritization. - */ - public TestRun startTestRunWithoutTestSelection() { - return new TestRun(api); - } - - /** - * Runs the TIA to determine which of the given available tests should be run and in which order. This method - * considers all changes since the last time that test-wise coverage was uploaded. In most situations this is the - * optimal behaviour. - * - * @param availableTests A list of all available tests. This is used to determine which tests need to be run, e.g. - * because they are completely new or changed since the last run. If you provide an empty - * list, no tests will be selected. The clustering information in this list is used to - * construct the test clusters in the returned {@link TestRunWithClusteredSuggestions}. - * @throws AgentHttpRequestFailedException e.g. if the agent or Teamscale is not reachable or an internal error - * occurs. This method already retries the request once, so this is likely a - * terminal failure. You should simply fall back to running all tests in - * this case and not communicate further with the agent. You should visibly - * report this problem so it can be fixed. - */ - public TestRunWithClusteredSuggestions startTestRun( - List availableTests) throws AgentHttpRequestFailedException { - return startTestRun(availableTests, null, null); - } - - /** - * Runs the TIA to determine which of the given available tests should be run and in which order. This method - * considers all changes since the last time that test-wise coverage was uploaded. In most situations this is the - * optimal behaviour. - *

- * Using this method, Teamscale will perform the selection and prioritization based on the tests it currently knows - * about. In this case, it will not automatically include changed or new tests in the selection (since it doesn't - * know about these changes) and it may return deleted tests (since it doesn't know about the deletions). It will - * also not cluster the tests as {@link #startTestRun(List)} would. - *

- * Thus, we recommend that, if possible, you use {@link #startTestRun(List)} instead. - * - * @throws AgentHttpRequestFailedException e.g. if the agent or Teamscale is not reachable or an internal error - * occurs. This method already retries the request once, so this is likely a - * terminal failure. You should simply fall back to running all tests in - * this case and not communicate further with the agent. You should visibly - * report this problem so it can be fixed. - */ - public TestRunWithFlatSuggestions startTestRunAssumingUnchangedTests() throws AgentHttpRequestFailedException { - return startTestRunAssumingUnchangedTests(null, null); - } - - /** - * Runs the TIA to determine which of the given available tests should be run and in which order. This method - * considers all changes since the given baseline timestamp. - * - * @param availableTests A list of all available tests. This is used to determine which tests need to be run, e.g. - * because they are completely new or changed since the last run. If you provide an empty - * list, no tests will be selected.The clustering information in this list is used to - * construct the test clusters in the returned {@link TestRunWithClusteredSuggestions}. - * @param baseline Consider all code changes since this date when calculating the impacted tests. - * @param baselineRevision Same as baseline but accepts a revision (e.g. git SHA1) instead of a branch and timestamp - * @throws AgentHttpRequestFailedException e.g. if the agent or Teamscale is not reachable or an internal error - * occurs. You should simply fall back to running all tests in this case. - */ - public TestRunWithClusteredSuggestions startTestRun(List availableTests, - Instant baseline, String baselineRevision) throws AgentHttpRequestFailedException { - if (availableTests == null) { - throw new IllegalArgumentException("availableTests must not be null. If you cannot provide a list of" + - " available tests, please use startTestRunAssumingUnchangedTests instead - but please be aware" + - " that this method of using the TIA cannot take into account changes in the tests themselves."); - } - Long baselineTimestamp = calculateBaselineTimestamp(baseline); - List clusters = AgentCommunicationUtils.handleRequestError( - () -> api.testRunStarted(includeNonImpactedTests, baselineTimestamp, baselineRevision, availableTests), - "Failed to start the test run"); - return new TestRunWithClusteredSuggestions(api, clusters); - } - - /** - * Runs the TIA to determine which of the given available tests should be run and in which order. This method - * considers all changes since the given baseline timestamp. - *

- * Using this method, Teamscale will perform the selection and prioritization based on the tests it currently knows - * about. In this case, it will not automatically include changed or new tests in the selection (since it doesn't - * know about these changes) and it may return deleted tests (since it doesn't know about the deletions). It will * - * also not cluster the tests as {@link #startTestRun(List, Instant, String)} would. - *

- * Thus, we recommend that, if possible, you use {@link #startTestRun(List, Instant, String)} - * instead. - * - * @throws AgentHttpRequestFailedException e.g. if the agent or Teamscale is not reachable or an internal error - * occurs. This method already retries the request once, so this is likely a - * terminal failure. You should simply fall back to running all tests in - * this case and not communicate further with the agent. You should visibly - * report this problem so it can be fixed. - */ - public TestRunWithFlatSuggestions startTestRunAssumingUnchangedTests( - Instant baseline, String baselineRevision) throws AgentHttpRequestFailedException { - Long baselineTimestamp = calculateBaselineTimestamp(baseline); - List clusters = AgentCommunicationUtils.handleRequestError( - () -> api.testRunStarted(includeNonImpactedTests, baselineTimestamp, baselineRevision), - "Failed to start the test run"); - return new TestRunWithFlatSuggestions(api, clusters.get(0).tests); - } - - private Long calculateBaselineTimestamp(Instant baseline) { - Long baselineTimestamp = null; - if (baseline != null) { - baselineTimestamp = baseline.toEpochMilli(); - } - return baselineTimestamp; - } - - -} diff --git a/tia-client/src/main/java/com/teamscale/tia.client/UrlUtils.java b/tia-client/src/main/java/com/teamscale/tia.client/UrlUtils.java deleted file mode 100644 index 84669da51..000000000 --- a/tia-client/src/main/java/com/teamscale/tia.client/UrlUtils.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.teamscale.tia.client; - -/** Utils for URL encoding as the Java internal URLEncoder does not handle all escape-worthy symbols for path segments. */ -public class UrlUtils { - - /** - * Percent-decodes a string, such as used in a URL Path (not a query string / form encode, which uses + for spaces, - * etc). - * Source: https://stackoverflow.com/a/44076794 - */ - public static String percentEncode(String encodeMe) { - if (encodeMe == null) { - return ""; - } - String encoded = encodeMe.replace("%", "%25"); - encoded = encoded.replace(" ", "%20"); - encoded = encoded.replace("!", "%21"); - encoded = encoded.replace("#", "%23"); - encoded = encoded.replace("$", "%24"); - encoded = encoded.replace("&", "%26"); - encoded = encoded.replace("'", "%27"); - encoded = encoded.replace("(", "%28"); - encoded = encoded.replace(")", "%29"); - encoded = encoded.replace("*", "%2A"); - encoded = encoded.replace("+", "%2B"); - encoded = encoded.replace(",", "%2C"); - encoded = encoded.replace("/", "%2F"); - encoded = encoded.replace(":", "%3A"); - encoded = encoded.replace(";", "%3B"); - encoded = encoded.replace("=", "%3D"); - encoded = encoded.replace("?", "%3F"); - encoded = encoded.replace("@", "%40"); - encoded = encoded.replace("[", "%5B"); - encoded = encoded.replace("]", "%5D"); - return encoded; - } -} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentCommunicationUtils.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentCommunicationUtils.kt new file mode 100644 index 000000000..a6e46c314 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentCommunicationUtils.kt @@ -0,0 +1,55 @@ +package com.teamscale.tia.client + +import com.teamscale.client.HttpUtils.getErrorBodyStringSafe +import retrofit2.Call +import java.io.IOException +import java.util.function.Supplier + +/** + * Utilities for performing requests to the agent. + */ +internal object AgentCommunicationUtils { + /** + * Performs the given request and handles common errors (e.g., network failures, internal exceptions in the agent). + * In case of network problems, retries the request once. + */ + @Throws(AgentHttpRequestFailedException::class) + fun handleRequestError(errorMessage: String, requestFactory: () -> Call) = + handleRequestError(requestFactory, errorMessage, true) + + @Throws(AgentHttpRequestFailedException::class) + private fun handleRequestError( + requestFactory: () -> Call, + errorMessage: String, + retryOnce: Boolean + ): T? { + try { + val response = requestFactory().execute() + if (response.isSuccessful) { + return response.body() + } + + val bodyString = getErrorBodyStringSafe(response) + throw AgentHttpRequestFailedException( + errorMessage + ". The agent responded with HTTP status " + response.code() + " " + response + .message() + ". Response body: " + bodyString + ) + } catch (e: IOException) { + if (!retryOnce) { + throw AgentHttpRequestFailedException( + errorMessage + ". I already retried this request and it failed twice (see the suppressed" + + " exception for details of the first failure). This is probably a network problem" + + " that you should address.", e + ) + } + + // retry once on network problems + try { + return handleRequestError(requestFactory, errorMessage, false) + } catch (t: Throwable) { + t.addSuppressed(e) + throw t + } + } + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentHttpRequestFailedException.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentHttpRequestFailedException.kt new file mode 100644 index 000000000..ac4590cad --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/AgentHttpRequestFailedException.kt @@ -0,0 +1,12 @@ +package com.teamscale.tia.client + +/** + * Thrown if communicating with the agent via HTTP fails. The underlying reason can be either a network problem or an + * internal error in the agent. Users of this library should report these exceptions appropriately so the underlying + * problems can be addressed. + */ +class AgentHttpRequestFailedException : Exception { + constructor(message: String) : super(message) + + constructor(message: String, cause: Throwable?) : super(message, cause) +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/CommandLineInterface.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/CommandLineInterface.kt new file mode 100644 index 000000000..010964325 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/CommandLineInterface.kt @@ -0,0 +1,172 @@ +package com.teamscale.tia.client + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.JsonUtils +import com.teamscale.client.JsonUtils.deserializeList +import com.teamscale.client.JsonUtils.serialize +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.tia.client.AgentCommunicationUtils.handleRequestError +import com.teamscale.tia.client.UrlUtils.encodeUrl +import okhttp3.HttpUrl +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets +import java.util.* +import java.util.stream.Collectors + +/** + * Simple command-line interface to expose the [TiaAgent] to non-Java test runners. + */ +class CommandLineInterface(arguments: Array) { + private class InvalidCommandLineException(message: String?) : RuntimeException(message) + + private val arguments = listOf(*arguments).toMutableList() + private val command: String + private val api: ITestwiseCoverageAgentApi + + init { + if (arguments.size < 2) { + throw InvalidCommandLineException( + "You must provide at least two arguments: the agent's URL and the command to execute" + ) + } + + val url = HttpUrl.parse(this.arguments.removeAt(0)) + api = ITestwiseCoverageAgentApi.createService(url!!) + + command = this.arguments.removeAt(0) + } + + @Throws(Exception::class) + private fun runCommand() { + when (command) { + "startTestRun" -> startTestRun() + "startTest" -> startTest() + "endTest" -> endTest() + "endTestRun" -> endTestRun() + else -> throw InvalidCommandLineException( + "Unknown command '$command'. Should be one of startTestRun, startTest, endTest, endTestRun" + ) + } + } + + @Throws(Exception::class) + private fun endTestRun() { + val partial = if (arguments.size == 1) { + arguments.removeAt(0).toBoolean() + } else { + false + } + handleRequestError( + "Failed to create a coverage report and upload it to Teamscale. The coverage is most likely lost" + ) { api.testRunFinished(partial) } + } + + @Throws(Exception::class) + private fun endTest() { + if (arguments.size < 2) { + throw InvalidCommandLineException( + "You must provide the uniform path of the test that is about to be started as the first argument of the endTest command and the test result as the second." + ) + } + val uniformPath = arguments.removeAt(0) + val result = ETestExecutionResult.valueOf(arguments.removeAt(0).uppercase(Locale.getDefault())) + val message = readStdin() + + // the agent already records test duration, so we can simply provide a dummy value here + val execution = TestExecution(uniformPath, 0L, result, message) + handleRequestError( + "Failed to end coverage recording for test case $uniformPath. Coverage for that test case is most likely lost." + ) { api.testFinished(uniformPath.encodeUrl(), execution) } + } + + @Throws(Exception::class) + private fun startTest() { + if (arguments.size < 1) { + throw InvalidCommandLineException( + "You must provide the uniform path of the test that is about to be started" + + " as the first argument of the startTest command" + ) + } + val uniformPath = arguments.removeAt(0) + handleRequestError( + "Failed to start coverage recording for test case $uniformPath. Coverage for that test case is lost." + ) { api.testStarted(uniformPath.encodeUrl()) } + } + + @Throws(Exception::class) + private fun startTestRun() { + val includeNonImpacted = parseAndRemoveBooleanSwitch("include-non-impacted") + val baseline = parseAndRemoveLongParameter("baseline") + val baselineRevision = parseAndRemoveStringParameter("baseline-revision") + val availableTests = parseAvailableTestsFromStdin() + + handleRequestError( + "Failed to start the test run" + ) { + api.testRunStarted(includeNonImpacted, baseline, baselineRevision, availableTests) + }?.let { + println(serialize(it)) + } + } + + @Throws(IOException::class) + private fun parseAvailableTestsFromStdin(): List { + val json = readStdin() + var availableTests = emptyList() + if (!isEmpty(json)) { + availableTests = deserializeList( + json, + ClusteredTestDetails::class.java + ) + } + return availableTests + } + + private fun readStdin(): String { + return BufferedReader(InputStreamReader(System.`in`, StandardCharsets.UTF_8)).lines() + .collect(Collectors.joining("\n")) + } + + private fun parseAndRemoveLongParameter(name: String): Long? { + for (i in arguments.indices) { + if (arguments[i].startsWith("--$name=")) { + val argument = arguments.removeAt(i) + return argument.substring(name.length + 3).toLong() + } + } + return null + } + + private fun parseAndRemoveBooleanSwitch(name: String): Boolean { + for (i in arguments.indices) { + if (arguments[i] == "--$name") { + arguments.removeAt(i) + return true + } + } + return false + } + + private fun parseAndRemoveStringParameter(name: String): String? { + for (i in arguments.indices) { + if (arguments[i].startsWith("--$name=")) { + val argument = arguments.removeAt(i) + return argument.substring(name.length + 3) + } + } + return null + } + + companion object { + /** Entry point. */ + @Throws(Exception::class) + @JvmStatic + fun main(arguments: Array) { + CommandLineInterface(arguments).runCommand() + } + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/ITestwiseCoverageAgentApi.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/ITestwiseCoverageAgentApi.kt new file mode 100644 index 000000000..8c5cfc76e --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/ITestwiseCoverageAgentApi.kt @@ -0,0 +1,100 @@ +package com.teamscale.tia.client + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.client.PrioritizableTestCluster +import com.teamscale.report.testwise.model.TestExecution +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory +import retrofit2.http.* +import java.util.concurrent.TimeUnit + +/** [Retrofit] API specification for the JaCoCo agent in test-wise coverage mode. */ +interface ITestwiseCoverageAgentApi { + /** Set the partition name as shown in Teamscale. */ + @PUT("partition") + fun setPartition(@Body partition: String): Call + + /** Set the revision as shown in Teamscale. */ + @PUT("revision") + fun setRevision(@Body partition: String): Call + + /** Set the upload commit as shown in Teamscale. */ + @PUT("commit") + fun setCommit(@Body commit: String): Call + + /** Set the commit message with which the upload is shown in Teamscale. */ + @PUT("message") + fun setMessage(@Body message: String): Call + + /** Test start. */ + @POST("test/start/{testUniformPath}") + fun testStarted(@Path(value = "testUniformPath", encoded = true) testUniformPath: String): Call + + /** Test finished. */ + @POST("test/end/{testUniformPath}") + fun testFinished( + @Path(value = "testUniformPath", encoded = true) testUniformPath: String + ): Call + + /** Test finished. */ + @POST("test/end/{testUniformPath}") + fun testFinished( + @Path(value = "testUniformPath", encoded = true) testUniformPath: String, + @Body testExecution: TestExecution + ): Call + + /** + * Test run started. Returns a single dummy cluster of TIA-selected and -prioritized tests + * that Teamscale currently knows about. + */ + @POST("testrun/start") + fun testRunStarted( + @Query("include-non-impacted") includeNonImpacted: Boolean, + @Query("baseline") baseline: Long?, + @Query("baseline-revision") baselineRevision: String? + ): Call> + + /** + * Test run started. Returns the list of TIA-selected and -prioritized test clusters to execute. + */ + @POST("testrun/start") + fun testRunStarted( + @Query("include-non-impacted") includeNonImpacted: Boolean, + @Query("baseline") baseline: Long?, + @Query("baseline-revision") baselineRevision: String?, + @Body availableTests: List + ): Call> + + /** + * Test run finished. Generate a test-wise coverage report and upload to Teamscale. + * + * @param partial Whether the test recording only contains a subset of the available tests. + */ + @POST("testrun/end") + fun testRunFinished(@Query("partial") partial: Boolean): Call + + companion object { + /** + * Generates a [Retrofit] instance for this service, which uses basic auth to authenticate against the server + * and which sets the Accept header to JSON. + */ + @JvmStatic + fun createService(baseUrl: HttpUrl): ITestwiseCoverageAgentApi { + val httpClientBuilder = OkHttpClient.Builder().apply { + connectTimeout(60, TimeUnit.SECONDS) + readTimeout(120, TimeUnit.SECONDS) + writeTimeout(60, TimeUnit.SECONDS) + } + return Retrofit.Builder() + .client(httpClientBuilder.build()) + .baseUrl(baseUrl) + .addConverterFactory(JacksonConverterFactory.create()) + .build() + .create(ITestwiseCoverageAgentApi::class.java) + } + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/RunningTest.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/RunningTest.kt new file mode 100644 index 000000000..444883be7 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/RunningTest.kt @@ -0,0 +1,114 @@ +package com.teamscale.tia.client + +import com.teamscale.client.JsonUtils.deserialize +import com.teamscale.client.StringUtils.isBlank +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.TestInfo +import com.teamscale.tia.client.AgentCommunicationUtils.handleRequestError +import com.teamscale.tia.client.TestRun.TestResultWithMessage +import com.teamscale.tia.client.UrlUtils.encodeUrl +import okhttp3.ResponseBody +import java.io.IOException + +/** + * Represents a single test that is currently being executed by the caller of this library. Use + * [.endTest] or [.endTestAndRetrieveCoverage] + * to signal that executing the test case has finished and test-wise coverage for this test should be stored. + */ +@Suppress("unused") +class RunningTest(private val uniformPath: String, private val api: ITestwiseCoverageAgentApi) { + private class AgentConfigurationMismatch(message: String?) : RuntimeException(message) + + /** + * Signals to the agent that the test runner has finished executing this test and the result of the test run. + * + * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This + * method already retries the request once, so this is likely a terminal + * failure. The caller should record this problem appropriately. Coverage + * for subsequent test cases could, however, potentially still be recorded. + * Thus, the caller should continue with test execution and continue + * informing the coverage agent about further test start and end events. + */ + @Throws(AgentHttpRequestFailedException::class) + fun endTest(result: TestResultWithMessage) { + // the agent already records test duration, so we can simply provide a dummy value here + val execution = TestExecution( + uniformPath, 0L, result.result, + result.message + ) + val body = handleRequestError( + "Failed to end coverage recording for test case $uniformPath. Coverage for that test case is most likely lost." + ) { api.testFinished(uniformPath.encodeUrl(), execution) } + + if (!readBodyStringNullSafe(body).isNullOrBlank()) { + throw AgentConfigurationMismatch( + "The agent seems to be configured to return test coverage via" + + " HTTP to the tia-client (agent option `tia-mode=http`) but you did not instruct the" + + " tia-client to handle this. Please either reconfigure the agent or call" + + " #endTestAndRetrieveCoverage() instead of this method and handle the returned coverage." + + " As it is currently configured, the agent will not store or process the recorded coverage" + + " in any way other than sending it to the tia-client via HTTP so it is lost permanently." + ) + } + } + + @Throws(AgentHttpRequestFailedException::class) + private fun readBodyStringNullSafe(body: ResponseBody?): String? { + if (body == null) { + return null + } + + try { + return body.string() + } catch (e: IOException) { + throw AgentHttpRequestFailedException("Unable to read agent HTTP response body string", e) + } + } + + /** + * Signals to the agent that the test runner has finished executing this test and the result of the test run. It + * will also parse the testwise coverage data returned by the agent for this test and return it so it can be + * manually processed by you. The agent will not store or otherwise process this coverage, so be sure to do so + * yourself. + * + * + * This method assumes that the agent is configured to return each test's coverage data via HTTP. It will receive + * and parse the data. If the agent is configured differently, this method will throw a terminal + * [RuntimeException]. In this case, you must reconfigure the agent with the `tia-mode=http` option enabled. + * + * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This + * method already retries the request once, so this is likely a terminal + * failure. The recorded coverage is likely lost. The caller should log this + * problem appropriately. + */ + @Throws(AgentHttpRequestFailedException::class) + fun endTestAndRetrieveCoverage( + result: TestResultWithMessage + ): TestInfo { + // the agent already records test duration, so we can simply provide a dummy value here + val execution = TestExecution( + uniformPath, 0L, result.result, result.message + ) + val body = handleRequestError( + "Failed to end coverage recording for test case $uniformPath. Coverage for that test case is most likely lost." + ) { api.testFinished(uniformPath.encodeUrl(), execution) } + + val json = readBodyStringNullSafe(body) + if (json.isNullOrBlank()) { + throw AgentConfigurationMismatch( + "You asked the tia-client to retrieve this test's coverage via HTTP" + + " but the agent is not configured for this. Please reconfigure the agent to use `tia-mode=http`." + ) + } + + try { + return deserialize(json, TestInfo::class.java) + } catch (e: IOException) { + throw AgentHttpRequestFailedException( + "Unable to parse the JSON returned by the agent. Maybe you have" + + " a version mismatch between the tia-client and the agent?. Json returned by the agent: `" + json + + "`", e + ) + } + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRun.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRun.kt new file mode 100644 index 000000000..53c8a1532 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRun.kt @@ -0,0 +1,62 @@ +package com.teamscale.tia.client + +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.tia.client.AgentCommunicationUtils.handleRequestError +import com.teamscale.tia.client.UrlUtils.encodeUrl + +/** + * Use this class to report test start and end events and upload testwise coverage to Teamscale. + * + * + * After having run all tests, call [.endTestRun] to create a testwise coverage report and upload it to + * Teamscale. This requires that you configured the agent to upload coverage to Teamscale + * (`tia-mode=teamscale-upload`). + */ +open class TestRun internal constructor(private val api: ITestwiseCoverageAgentApi) { + /** + * Represents the result of running a single test. + */ + class TestResultWithMessage( + /** Whether the test succeeded or failed. */ + val result: ETestExecutionResult, + /** An optional message, e.g. a stack trace in case of test failures. */ + val message: String? + ) + + /** + * Informs the testwise coverage agent that a new test is about to start. + * + * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. In + * this case, the agent probably doesn't know that this test case was + * started, so its coverage is lost. This method already retries the request + * once, so this is likely a terminal failure. The caller should log this + * problem appropriately. Coverage for subsequent test cases could, however, + * potentially still be recorded. Thus, the caller should continue with test + * execution and continue informing the coverage agent about further test + * start and end events. + */ + @Throws(AgentHttpRequestFailedException::class) + fun startTest(uniformPath: String): RunningTest { + handleRequestError( + "Failed to start coverage recording for test case $uniformPath" + ) { api.testStarted(uniformPath.encodeUrl()) } + return RunningTest(uniformPath, api) + } + + /** + * Informs the testwise coverage agent that the caller has finished running all tests and should upload coverage to + * Teamscale. Only call this if you configured the agent to upload coverage to Teamscale + * (`tia-mode=teamscale-upload`). Otherwise, this method will throw an exception. + * + * @throws AgentHttpRequestFailedException if communicating with the agent fails or in case of internal errors. This + * method already retries the request once, so this is likely a terminal + * failure. The recorded coverage is likely lost. The caller should log this + * problem appropriately. + */ + @Throws(AgentHttpRequestFailedException::class) + fun endTestRun(partial: Boolean) { + handleRequestError( + "Failed to create a coverage report and upload it to Teamscale. The coverage is most likely lost" + ) { api.testRunFinished(partial) } + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithClusteredSuggestions.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithClusteredSuggestions.kt new file mode 100644 index 000000000..c8ca10ea8 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithClusteredSuggestions.kt @@ -0,0 +1,20 @@ +package com.teamscale.tia.client + +import com.teamscale.client.PrioritizableTestCluster + +/** + * Represents a run of prioritized and selected test clusters as reported by the TIA. Use this class to report test + * start and end events and upload testwise coverage to Teamscale. + * + * + * The caller of this class should retrieve the test clusters to execute from [.getPrioritizedClusters], run + * them (in the given order if possible) and report test start and end events via [.startTest] )}. + * + * + * After having run all tests, call [.endTestRun] to create a testwise coverage report and upload it to + * Teamscale. + */ +class TestRunWithClusteredSuggestions internal constructor( + api: ITestwiseCoverageAgentApi, + @JvmField val prioritizedClusters: List? +) : TestRun(api) diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithFlatSuggestions.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithFlatSuggestions.kt new file mode 100644 index 000000000..3f9a5f0bc --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/TestRunWithFlatSuggestions.kt @@ -0,0 +1,20 @@ +package com.teamscale.tia.client + +import com.teamscale.client.PrioritizableTest + +/** + * Represents a run of a flat list of prioritized and selected tests as reported by the TIA. Use this class to report + * test start and end events and upload testwise coverage to Teamscale. + * + * + * The caller of this class should retrieve the tests to execute from [prioritizedTests], run them (in the + * given order if possible) and report test start and end events via [startTest])}. + * + * + * After having run all tests, call [endTestRun] to create a testwise coverage report and upload it to + * Teamscale. + */ +class TestRunWithFlatSuggestions internal constructor( + api: ITestwiseCoverageAgentApi, + private val prioritizedTests: List +) : TestRun(api) diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/TiaAgent.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/TiaAgent.kt new file mode 100644 index 000000000..42b765a58 --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/TiaAgent.kt @@ -0,0 +1,142 @@ +package com.teamscale.tia.client + +import com.teamscale.client.ClusteredTestDetails +import com.teamscale.tia.client.AgentCommunicationUtils.handleRequestError +import okhttp3.HttpUrl +import java.time.Instant + +/** + * Communicates with one Teamscale JaCoCo agent in testwise coverage mode to facilitate the Test Impact analysis. + * + * + * Use this class to retrieve impacted tests from Teamscale, start a [TestRun] based on these selected and + * prioritized tests and upload test-wise coverage after having executed the tests. + * + * + * The caller of this class is responsible for actually executing the tests. + * + *

Available Tests

+ * This API allows you to pass a list of available tests to start a test run. This list is used for multiple purposes: + * + * + * * To determine if any tests changed, were added or removed: Teamscale will not suggest deleted tests and + * will always suggest changed or added tests. + * * To cluster tests: The list of available tests includes information about test clusters, which are logical + * groups of tests. Teamscale will only prioritize tests against each other if they are in the same cluster. + * + */ +class TiaAgent(private val includeNonImpactedTests: Boolean, url: HttpUrl) { + private val api = ITestwiseCoverageAgentApi.createService(url) + + /** + * Starts a test run but does not ask Teamscale to prioritize and select any test cases. Use this when you only want + * to record test-wise coverage and don't care about TIA's test selection and prioritization. + */ + fun startTestRunWithoutTestSelection() = TestRun(api) + + /** + * Runs the TIA to determine which of the given available tests should be run and in which order. This method + * considers all changes since the last time that test-wise coverage was uploaded. In most situations this is the + * optimal behaviour. + * + * @param availableTests A list of all available tests. This is used to determine which tests need to be run, e.g. + * because they are completely new or changed since the last run. If you provide an empty + * list, no tests will be selected. The clustering information in this list is used to + * construct the test clusters in the returned [TestRunWithClusteredSuggestions]. + * @throws AgentHttpRequestFailedException e.g. if the agent or Teamscale is not reachable or an internal error + * occurs. This method already retries the request once, so this is likely a + * terminal failure. You should simply fall back to running all tests in + * this case and not communicate further with the agent. You should visibly + * report this problem so it can be fixed. + */ + @Throws(AgentHttpRequestFailedException::class) + fun startTestRun( + availableTests: List + ) = startTestRun(availableTests, null, null) + + /** + * Runs the TIA to determine which of the given available tests should be run and in which order. This method + * has considered all changes since the last time that test-wise coverage was uploaded. + * In most situations, this is the optimal behavior. + * + * + * Using this method, Teamscale will perform the selection and prioritization based on the tests it currently knows + * about. In this case, it will not automatically include changed or new tests in the selection (since it doesn't + * know about these changes) and it may return deleted tests (since it doesn't know about the deletions). It will + * also not cluster the tests as [.startTestRun] would. + * + * + * **Thus, we recommend that, if possible, you use [.startTestRun] instead.** + * + * @throws AgentHttpRequestFailedException e.g., if the agent or Teamscale is not reachable or an internal error + * occurs. This method already retries the request once, so this is likely a + * terminal failure. You should fall back to running all tests in + * this case and not communicate further with the agent. You should visibly + * report this problem so it can be fixed. + */ + @Throws(AgentHttpRequestFailedException::class) + fun startTestRunAssumingUnchangedTests() = + startTestRunAssumingUnchangedTests(null, null) + + /** + * Runs the TIA to determine which of the given available tests should be run and in which order. This method + * has considered all changes since the given baseline timestamp. + * + * @param availableTests A list of all available tests. This is used to determine which tests need to be run, e.g., + * because they are completely new or changed since the last run. If you provide an empty + * list, no tests will be selected. The clustering information in this list is used to + * construct the test clusters in the returned [TestRunWithClusteredSuggestions]. + * @param baseline Consider all code changes since this date when calculating the impacted tests. + * @param baselineRevision Same as baseline but accepts a revision (e.g., git SHA1) instead of a branch and timestamp + * @throws AgentHttpRequestFailedException e.g., if the agent or Teamscale is not reachable or an internal error + * occurs. + * You should fall back to running all tests in this case. + */ + @Throws(AgentHttpRequestFailedException::class) + fun startTestRun( + availableTests: List, + baseline: Instant?, + baselineRevision: String? + ): TestRunWithClusteredSuggestions { + val baselineTimestamp = baseline?.toEpochMilli() + val clusters = handleRequestError("Failed to start the test run") { + api.testRunStarted( + includeNonImpactedTests, + baselineTimestamp, + baselineRevision, + availableTests + ) + } + return TestRunWithClusteredSuggestions(api, clusters) + } + + /** + * Runs the TIA to determine which of the given available tests should be run and in which order. This method + * has considered all changes since the given baseline timestamp. + * + * + * Using this method, Teamscale will perform the selection and prioritization based on the tests it currently knows + * about. In this case, it will not automatically include changed or new tests in the selection (since it doesn't + * know about these changes) and it may return deleted tests (since it doesn't know about the deletions). It will * + * also not cluster the tests as [startTestRun] would. + * + * + * **Thus, we recommend that, if possible, you use [startTestRun] + * instead.** + * + * @throws AgentHttpRequestFailedException e.g., if the agent or Teamscale is not reachable or an internal error + * occurs. This method already retries the request once, so this is likely a + * terminal failure. You should fall back to running all tests in + * this case and not communicate further with the agent. You should visibly + * report this problem so it can be fixed. + */ + @Throws(AgentHttpRequestFailedException::class) + fun startTestRunAssumingUnchangedTests( + baseline: Instant?, baselineRevision: String? + ): TestRunWithFlatSuggestions { + val clusters = handleRequestError("Failed to start the test run") { + api.testRunStarted(includeNonImpactedTests, baseline?.toEpochMilli(), baselineRevision) + } + return TestRunWithFlatSuggestions(api, clusters?.firstOrNull()?.tests ?: emptyList()) + } +} diff --git a/tia-client/src/main/kotlin/com/teamscale/tia/client/UrlUtils.kt b/tia-client/src/main/kotlin/com/teamscale/tia/client/UrlUtils.kt new file mode 100644 index 000000000..7f207babe --- /dev/null +++ b/tia-client/src/main/kotlin/com/teamscale/tia/client/UrlUtils.kt @@ -0,0 +1,32 @@ +package com.teamscale.tia.client + +/** Utils for URL encoding as the Java internal URLEncoder does not handle all escape-worthy symbols for path segments. */ +object UrlUtils { + /** + * Percent-decodes a string, such as used in a URL Path (not a query string / form encode, which uses + for spaces, + * etc). + * Source: https://stackoverflow.com/a/44076794 + */ + @JvmStatic + fun String.encodeUrl() = + replace("%", "%25") + .replace(" ", "%20") + .replace("!", "%21") + .replace("#", "%23") + .replace("$", "%24") + .replace("&", "%26") + .replace("'", "%27") + .replace("(", "%28") + .replace(")", "%29") + .replace("*", "%2A") + .replace("+", "%2B") + .replace(",", "%2C") + .replace("/", "%2F") + .replace(":", "%3A") + .replace(";", "%3B") + .replace("=", "%3D") + .replace("?", "%3F") + .replace("@", "%40") + .replace("[", "%5B") + .replace("]", "%5D") +} diff --git a/tia-client/src/test/java/com/teamscale/tia/client/AgentCommunicationUtilsTest.java b/tia-client/src/test/java/com/teamscale/tia/client/AgentCommunicationUtilsTest.java deleted file mode 100644 index a1e6312f5..000000000 --- a/tia-client/src/test/java/com/teamscale/tia/client/AgentCommunicationUtilsTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.teamscale.tia.client; - -import okhttp3.ResponseBody; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.SocketPolicy; -import org.junit.jupiter.api.Test; -import retrofit2.Call; -import retrofit2.Retrofit; -import retrofit2.http.GET; - -import java.io.IOException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class AgentCommunicationUtilsTest { - - private interface ITestService { - - /** Test request. */ - @GET("request") - Call testRequest(); - } - - @Test - public void shouldRetryRequestsOnce() throws Exception { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); - server.enqueue(new MockResponse().setResponseCode(200).setBody("result")); - - ITestService service = new Retrofit.Builder().baseUrl("http://localhost:" + server.getPort()).build() - .create(ITestService.class); - - // should not throw since the second call works - AgentCommunicationUtils.handleRequestError(service::testRequest, "test"); - } - - @Test - public void shouldNotRetryIfFirstRequestSucceeds() throws Exception { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setResponseCode(200).setBody("result")); - server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); - - ITestService service = new Retrofit.Builder().baseUrl("http://localhost:" + server.getPort()).build() - .create(ITestService.class); - - // should not throw since the first call works - AgentCommunicationUtils.handleRequestError(service::testRequest, "test"); - } - - @Test - public void shouldNotRetryMoreThanOnce() { - MockWebServer server = new MockWebServer(); - server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); - server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); - server.enqueue(new MockResponse().setResponseCode(200).setBody("result")); - - ITestService service = new Retrofit.Builder().baseUrl("http://localhost:" + server.getPort()).build() - .create(ITestService.class); - - AgentHttpRequestFailedException exception = assertThrows(AgentHttpRequestFailedException.class, - () -> AgentCommunicationUtils.handleRequestError(service::testRequest, "test")); - assertThat(exception.getCause()).isInstanceOf(IOException.class); - } - -} \ No newline at end of file diff --git a/tia-client/src/test/kotlin/com/teamscale/tia/client/AgentCommunicationUtilsTest.kt b/tia-client/src/test/kotlin/com/teamscale/tia/client/AgentCommunicationUtilsTest.kt new file mode 100644 index 000000000..445b7f9fd --- /dev/null +++ b/tia-client/src/test/kotlin/com/teamscale/tia/client/AgentCommunicationUtilsTest.kt @@ -0,0 +1,73 @@ +package com.teamscale.tia.client + +import com.teamscale.tia.client.AgentCommunicationUtils.handleRequestError +import okhttp3.ResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import retrofit2.Call +import retrofit2.Retrofit +import retrofit2.http.GET +import java.io.IOException +import java.util.function.Supplier + +class AgentCommunicationUtilsTest { + private interface ITestService { + /** Test request. */ + @GET("request") + fun testRequest(): Call + } + + @Test + @Throws(Exception::class) + fun shouldRetryRequestsOnce() { + val server = MockWebServer().apply { + enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + enqueue(MockResponse().setResponseCode(200).setBody("result")) + } + + val service = Retrofit.Builder().baseUrl("http://localhost:" + server.port) + .build() + .create(ITestService::class.java) + + // should not throw since the second call works + handleRequestError("test") { service.testRequest() } + } + + @Test + @Throws(Exception::class) + fun shouldNotRetryIfFirstRequestSucceeds() { + val server = MockWebServer().apply { + enqueue(MockResponse().setResponseCode(200).setBody("result")) + enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + } + + val service = Retrofit.Builder().baseUrl("http://localhost:" + server.port) + .build() + .create(ITestService::class.java) + + // should not throw since the first call works + handleRequestError("test") { service.testRequest() } + } + + @Test + fun shouldNotRetryMoreThanOnce() { + val server = MockWebServer().apply { + enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + enqueue(MockResponse().setResponseCode(200).setBody("result")) + } + + val exception = Assertions.assertThrows( + AgentHttpRequestFailedException::class.java + ) { + val service = Retrofit.Builder().baseUrl("http://localhost:" + server.port) + .build() + .create(ITestService::class.java) + handleRequestError("test") { service.testRequest() } + } + org.assertj.core.api.Assertions.assertThat(exception.cause).isInstanceOf(IOException::class.java) + } +} \ No newline at end of file diff --git a/tia-runlisteners/build.gradle.kts b/tia-runlisteners/build.gradle.kts index 89486d4e8..c8f590315 100644 --- a/tia-runlisteners/build.gradle.kts +++ b/tia-runlisteners/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + kotlin("jvm") `java-library` com.teamscale.`java-convention` com.teamscale.coverage diff --git a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.java b/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.java deleted file mode 100644 index 490cedda9..000000000 --- a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.teamscale.tia.runlistener; - -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.tia.client.TiaAgent; -import org.junit.runner.Description; -import org.junit.runner.Result; -import org.junit.runner.notification.Failure; -import org.junit.runner.notification.RunListener; - -/** - * {@link RunListener} that uses the {@link TiaAgent} to record test-wise coverage. - */ -@SuppressWarnings("unused") -public class JUnit4TestwiseCoverageRunListener extends RunListener { - - private final RunListenerAgentBridge bridge = new RunListenerAgentBridge( - JUnit4TestwiseCoverageRunListener.class.getName()); - - @Override - public void testStarted(Description description) { - String uniformPath = getUniformPath(description); - bridge.testStarted(uniformPath); - } - - private String getUniformPath(Description description) { - String uniformPath = description.getClassName().replace('.', '/'); - if (description.getMethodName() != null) { - uniformPath += "/" + description.getMethodName(); - } - return uniformPath; - } - - @Override - public void testFinished(Description description) { - String uniformPath = getUniformPath(description); - bridge.testFinished(uniformPath, ETestExecutionResult.PASSED); - } - - @Override - public void testFailure(Failure failure) { - String uniformPath = getUniformPath(failure.getDescription()); - bridge.testFinished(uniformPath, ETestExecutionResult.FAILURE, failure.getMessage()); - } - - @Override - public void testAssumptionFailure(Failure failure) { - String uniformPath = getUniformPath(failure.getDescription()); - bridge.testFinished(uniformPath, ETestExecutionResult.FAILURE); - } - - @Override - public void testIgnored(Description description) { - String uniformPath = getUniformPath(description); - bridge.testSkipped(uniformPath, null); - } - - @Override - public void testRunFinished(Result result) { - bridge.testRunFinished(); - } -} diff --git a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.java b/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.java deleted file mode 100644 index 7e4e36b23..000000000 --- a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.java +++ /dev/null @@ -1,85 +0,0 @@ -package com.teamscale.tia.runlistener; - -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.tia.client.TiaAgent; -import org.junit.platform.engine.TestExecutionResult; -import org.junit.platform.engine.TestSource; -import org.junit.platform.engine.support.descriptor.ClassSource; -import org.junit.platform.engine.support.descriptor.MethodSource; -import org.junit.platform.launcher.TestExecutionListener; -import org.junit.platform.launcher.TestIdentifier; -import org.junit.platform.launcher.TestPlan; - -import java.util.Optional; - -/** - * {@link TestExecutionListener} that uses the {@link TiaAgent} to record test-wise coverage. - */ -@SuppressWarnings("unused") -public class JUnit5TestwiseCoverageExecutionListener implements TestExecutionListener { - - private final RunListenerAgentBridge bridge = new RunListenerAgentBridge( - JUnit5TestwiseCoverageExecutionListener.class.getName()); - - @Override - public void executionStarted(TestIdentifier testIdentifier) { - if (!testIdentifier.isTest()) { - return; - } - String uniformPath = getUniformPath(testIdentifier); - bridge.testStarted(uniformPath); - } - - private String getUniformPath(TestIdentifier testIdentifier) { - return testIdentifier.getSource().flatMap(this::parseTestSource).orElse(testIdentifier.getDisplayName()); - } - - private Optional parseTestSource(TestSource source) { - if (source instanceof ClassSource) { - ClassSource classSource = (ClassSource) source; - return Optional.of(classSource.getClassName().replace('.', '/')); - } else if (source instanceof MethodSource) { - MethodSource methodSource = (MethodSource) source; - return Optional.of(methodSource.getClassName().replace('.', '/') + "/" + - methodSource.getMethodName() + "(" + methodSource.getMethodParameterTypes() + ")"); - } - return Optional.empty(); - } - - @Override - public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) { - if (!testIdentifier.isTest()) { - return; - } - String uniformPath = getUniformPath(testIdentifier); - ETestExecutionResult result; - switch (testExecutionResult.getStatus()) { - case SUCCESSFUL: - result = ETestExecutionResult.PASSED; - break; - case ABORTED: - result = ETestExecutionResult.ERROR; - break; - case FAILED: - default: - result = ETestExecutionResult.FAILURE; - break; - } - - bridge.testFinished(uniformPath, result); - } - - @Override - public void executionSkipped(TestIdentifier testIdentifier, String reason) { - if (testIdentifier.isContainer()) { - return; - } - String uniformPath = getUniformPath(testIdentifier); - bridge.testSkipped(uniformPath, reason); - } - - @Override - public void testPlanExecutionFinished(TestPlan testPlan) { - bridge.testRunFinished(); - } -} diff --git a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerAgentBridge.java b/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerAgentBridge.java deleted file mode 100644 index 400af387a..000000000 --- a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerAgentBridge.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.teamscale.tia.runlistener; - -import com.teamscale.report.testwise.model.ETestExecutionResult; -import com.teamscale.tia.client.RunningTest; -import com.teamscale.tia.client.TestRun; -import com.teamscale.tia.client.TiaAgent; -import okhttp3.HttpUrl; -import org.junit.runner.notification.RunListener; - -/** - * Handles communication with the {@link TiaAgent} and logging for any type of test run listener. This allows e.g. Junit - * 4 and Junit 5 listeners to share the same logic for these tasks. - */ -public class RunListenerAgentBridge { - - private final TestRun testRun; - private RunningTest runningTest; - private final RunListenerLogger logger = new RunListenerLogger(RunListenerAgentBridge.class); - - private static class RunListenerConfigurationException extends RuntimeException { - public RunListenerConfigurationException(String message) { - super(message); - } - } - - public RunListenerAgentBridge(String runListenerClassName) { - logger.debug(runListenerClassName + " instantiated"); - String agentUrl = System.getProperty("tia.agent"); - if (agentUrl == null) { - agentUrl = System.getenv("TIA_AGENT"); - } - if (agentUrl == null) { - RunListenerConfigurationException exception = new RunListenerConfigurationException( - "You did not provide the URL of a Teamscale JaCoCo agent that will record test-wise coverage." + - " You can configure the URL either as a system property with -Dtia.agent=URL" + - " or as an environment variable with TIA_AGENT=URL."); - logger.error("Failed to instantiate " + runListenerClassName, exception); - throw exception; - } - - TiaAgent agent = new TiaAgent(false, HttpUrl.get(agentUrl)); - testRun = agent.startTestRunWithoutTestSelection(); - } - - @FunctionalInterface - private interface Action { - /** Runs the action, throwing exceptions if it fails. */ - void run() throws Exception; - } - - /** - * We mustn't throw exceptions out of the {@link RunListener} interface methods or Maven will treat the test as - * failed. And we don't have access to the Maven logger, so we just log to stderr. - */ - private void handleErrors(Action action, String description) { - try { - action.run(); - } catch (Exception e) { - logger.error("Encountered an error while recording test-wise coverage in step: " + description, e); - } - } - - /** Notifies the {@link TiaAgent} that the given test was started. */ - public void testStarted(String uniformPath) { - logger.debug("Started test '" + uniformPath + "'"); - handleErrors(() -> runningTest = testRun.startTest(uniformPath), "Starting test '" + uniformPath + "'"); - } - - /** Notifies the {@link TiaAgent} that the given test was finished (both successfully and unsuccessfully). */ - public void testFinished(String uniformPath, ETestExecutionResult result) { - testFinished(uniformPath, result, null); - } - - /** - * Notifies the {@link TiaAgent} that the given test was finished (both successfully and unsuccessfully). - * - * @param message mey be null if no useful message can be provided. - */ - public void testFinished(String uniformPath, ETestExecutionResult result, String message) { - logger.debug("Finished test '" + uniformPath + "'"); - handleErrors(() -> { - if (runningTest != null) { - runningTest.endTest(new TestRun.TestResultWithMessage(result, message)); - runningTest = null; - } - }, "Finishing test '" + uniformPath + "'"); - } - - /** - * Notifies the {@link TiaAgent} that the given test was skipped. - * - * @param reason Optional reason. Pass null if no reason was provided by the test framework. - */ - public void testSkipped(String uniformPath, String reason) { - logger.debug("Skipped test '" + uniformPath + "'"); - handleErrors(() -> { - if (runningTest != null) { - runningTest.endTest(new TestRun.TestResultWithMessage(ETestExecutionResult.SKIPPED, reason)); - runningTest = null; - } - }, "Skipping test '" + uniformPath + "'"); - } - - /** - * Notifies the {@link TiaAgent} that the whole test run is finished and that test-wise coverage recording can end - * now. - */ - public void testRunFinished() { - logger.debug("Finished test run"); - handleErrors(() -> testRun.endTestRun(false), "Finishing the test run"); - } -} diff --git a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerLogger.java b/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerLogger.java deleted file mode 100644 index c9160b229..000000000 --- a/tia-runlisteners/src/main/java/com/teamscale/tia/runlistener/RunListenerLogger.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.teamscale.tia.runlistener; - -/** - * Implements simple STDOUT logging for run listeners. We cannot use SLF4J, since the run listeners are executed in the - * context of the system under test, where we cannot rely on any SLF4J bindings to be present. - *

- * To enable debug logging, specify {@code -Dtia.debug} for the JVM containing the run listener. - */ -public class RunListenerLogger { - - private static final boolean DEBUG_ENABLED = Boolean.getBoolean("tia.debug"); - - private final String callerClassName; - - public RunListenerLogger(Class callerClass) { - callerClassName = callerClass.getSimpleName(); - } - - /** Logs a debug message. */ - public void debug(String message) { - if (!DEBUG_ENABLED) { - return; - } - // we log to System.err instead of System.out as some runners will filter System.out, e.g. Maven - System.err.println("[DEBUG] " + callerClassName + " - " + message); - } - - /** Logs an error message and the stack trace of an optional throwable. */ - public void error(String message, Throwable throwable) { - System.err.println("[ERROR] " + callerClassName + " - " + message); - throwable.printStackTrace(); - } - -} diff --git a/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.kt b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.kt new file mode 100644 index 000000000..aa3c6170a --- /dev/null +++ b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit4TestwiseCoverageRunListener.kt @@ -0,0 +1,52 @@ +package com.teamscale.tia.runlistener + +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.tia.client.TiaAgent +import org.junit.runner.Description +import org.junit.runner.Result +import org.junit.runner.notification.Failure +import org.junit.runner.notification.RunListener + +/** + * [RunListener] that uses the [TiaAgent] to record test-wise coverage. + */ +class JUnit4TestwiseCoverageRunListener : RunListener() { + private val bridge = RunListenerAgentBridge.create() + + override fun testStarted(description: Description) { + val uniformPath = getUniformPath(description) + bridge.testStarted(uniformPath) + } + + private fun getUniformPath(description: Description): String { + var uniformPath = description.className.replace('.', '/') + if (description.methodName != null) { + uniformPath += "/" + description.methodName + } + return uniformPath + } + + override fun testFinished(description: Description) { + val uniformPath = getUniformPath(description) + bridge.testFinished(uniformPath, ETestExecutionResult.PASSED) + } + + override fun testFailure(failure: Failure) { + val uniformPath = getUniformPath(failure.description) + bridge.testFinished(uniformPath, ETestExecutionResult.FAILURE, failure.message) + } + + override fun testAssumptionFailure(failure: Failure) { + val uniformPath = getUniformPath(failure.description) + bridge.testFinished(uniformPath, ETestExecutionResult.FAILURE) + } + + override fun testIgnored(description: Description) { + val uniformPath = getUniformPath(description) + bridge.testSkipped(uniformPath, null) + } + + override fun testRunFinished(result: Result) { + bridge.testRunFinished() + } +} diff --git a/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.kt b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.kt new file mode 100644 index 000000000..0cc022695 --- /dev/null +++ b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/JUnit5TestwiseCoverageExecutionListener.kt @@ -0,0 +1,66 @@ +package com.teamscale.tia.runlistener + +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.tia.client.TiaAgent +import org.junit.platform.engine.TestExecutionResult +import org.junit.platform.engine.TestSource +import org.junit.platform.engine.support.descriptor.ClassSource +import org.junit.platform.engine.support.descriptor.MethodSource +import org.junit.platform.launcher.TestExecutionListener +import org.junit.platform.launcher.TestIdentifier +import org.junit.platform.launcher.TestPlan +import java.util.* +import java.util.function.Function + +/** + * [TestExecutionListener] that uses the [TiaAgent] to record test-wise coverage. + */ +class JUnit5TestwiseCoverageExecutionListener : TestExecutionListener { + private val bridge = RunListenerAgentBridge.create() + + override fun executionStarted(testIdentifier: TestIdentifier) { + if (!testIdentifier.isTest) return + val uniformPath = getUniformPath(testIdentifier) + bridge.testStarted(uniformPath) + } + + private fun getUniformPath(testIdentifier: TestIdentifier) = + testIdentifier.source.flatMap { source -> + parseTestSource(source) + }.orElse(testIdentifier.displayName) + + private fun parseTestSource(source: TestSource) = + when (source) { + is ClassSource -> Optional.of(source.className.replace('.', '/')) + is MethodSource -> Optional.of( + source.className.replace('.', '/') + "/" + + source.methodName + "(" + source.methodParameterTypes + ")" + ) + else -> Optional.empty() + } + + override fun executionFinished(testIdentifier: TestIdentifier, testExecutionResult: TestExecutionResult) { + if (!testIdentifier.isTest) { + return + } + val uniformPath = getUniformPath(testIdentifier) + val result = when (testExecutionResult.status) { + TestExecutionResult.Status.SUCCESSFUL -> ETestExecutionResult.PASSED + TestExecutionResult.Status.ABORTED -> ETestExecutionResult.ERROR + TestExecutionResult.Status.FAILED -> ETestExecutionResult.FAILURE + else -> ETestExecutionResult.FAILURE + } + + bridge.testFinished(uniformPath, result) + } + + override fun executionSkipped(testIdentifier: TestIdentifier, reason: String) { + if (testIdentifier.isContainer) return + val uniformPath = getUniformPath(testIdentifier) + bridge.testSkipped(uniformPath, reason) + } + + override fun testPlanExecutionFinished(testPlan: TestPlan) { + bridge.testRunFinished() + } +} diff --git a/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerAgentBridge.kt b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerAgentBridge.kt new file mode 100644 index 000000000..9c48f6b6b --- /dev/null +++ b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerAgentBridge.kt @@ -0,0 +1,93 @@ +package com.teamscale.tia.runlistener + +import com.teamscale.report.testwise.model.ETestExecutionResult +import com.teamscale.tia.client.RunningTest +import com.teamscale.tia.client.TestRun +import com.teamscale.tia.client.TestRun.TestResultWithMessage +import com.teamscale.tia.client.TiaAgent +import okhttp3.HttpUrl + +/** + * Handles communication with the [TiaAgent] and logging for any type of test run listener. + * This allows, e.g., Junit 4 and Junit 5 listeners to share the same logic for these tasks. + */ +class RunListenerAgentBridge(runListenerClassName: String) { + private val testRun: TestRun + private var runningTest: RunningTest? = null + private val logger = RunListenerLogger.create() + + private class RunListenerConfigurationException(message: String) : RuntimeException(message) + + init { + logger.debug("$runListenerClassName instantiated") + val agentUrl = System.getProperty("tia.agent") ?: System.getenv("TIA_AGENT") + if (agentUrl == null) { + val exception = RunListenerConfigurationException( + "You did not provide the URL of a Teamscale JaCoCo agent that will record test-wise coverage." + + " You can configure the URL either as a system property with -Dtia.agent=URL" + + " or as an environment variable with TIA_AGENT=URL." + ) + logger.error("Failed to instantiate $runListenerClassName", exception) + throw exception + } + + val agent = TiaAgent(false, HttpUrl.get(agentUrl)) + testRun = agent.startTestRunWithoutTestSelection() + } + + private fun handleErrors(description: String, action: () -> Unit) { + runCatching { action() }.onFailure { e -> + logger.error("Encountered an error while recording test-wise coverage in step: $description", e) + } + } + + /** Notifies the [TiaAgent] that the given test was started. */ + fun testStarted(uniformPath: String) { + logger.debug("Started test '$uniformPath'") + handleErrors("Starting test '$uniformPath'") { + runningTest = testRun.startTest(uniformPath) + } + } + + /** + * Notifies the [TiaAgent] that the given test was finished (both successfully and unsuccessfully). + * + * @param message may be null if no useful message can be provided. + */ + @JvmOverloads + fun testFinished(uniformPath: String, result: ETestExecutionResult, message: String? = null) { + logger.debug("Finished test '$uniformPath'") + handleErrors("Finishing test '$uniformPath'") { + runningTest?.endTest(TestResultWithMessage(result, message)) + runningTest = null + } + } + + /** + * Notifies the [TiaAgent] that the given test was skipped. + * + * @param reason Optional reason. Pass null if no reason was provided by the test framework. + */ + fun testSkipped(uniformPath: String, reason: String?) { + logger.debug("Skipped test '$uniformPath'") + handleErrors("Skipping test '$uniformPath'") { + runningTest?.endTest(TestResultWithMessage(ETestExecutionResult.SKIPPED, reason)) + runningTest = null + } + } + + /** + * Notifies the [TiaAgent] that the whole test run is finished and that test-wise coverage recording can end + * now. + */ + fun testRunFinished() { + logger.debug("Finished test run") + handleErrors("Finishing the test run") { + testRun.endTestRun(false) + } + } + + companion object { + inline fun create() = RunListenerAgentBridge(T::class.java.name) + } +} \ No newline at end of file diff --git a/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerLogger.kt b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerLogger.kt new file mode 100644 index 000000000..a54fe3ba1 --- /dev/null +++ b/tia-runlisteners/src/main/kotlin/com/teamscale/tia/runlistener/RunListenerLogger.kt @@ -0,0 +1,29 @@ +package com.teamscale.tia.runlistener + +/** + * Implements simple STDOUT logging for run listeners. We cannot use SLF4J, since the run listeners are executed in the + * context of the system under test, where we cannot rely on any SLF4J bindings to be present. + * + * To enable debug logging, specify `-Dtia.debug` for the JVM containing the run listener. + */ +class RunListenerLogger(private val callerClassName: String) { + private val debugEnabled: Boolean get() = + java.lang.Boolean.getBoolean("tia.debug") + + /** Logs a debug message. */ + fun debug(message: String) { + if (!debugEnabled) return + // we log to System.err instead of System.out as some runners will filter System.out, e.g. Maven + System.err.println("[DEBUG] $callerClassName - $message") + } + + /** Logs an error message and the stack trace of an optional throwable. */ + fun error(message: String, throwable: Throwable) { + System.err.println("[ERROR] $callerClassName - $message") + throwable.printStackTrace() + } + + companion object { + inline fun create() = RunListenerLogger(T::class.java.simpleName) + } +}