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 3dc8e196b..25e9dc5d8 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 @@ -5,6 +5,7 @@ 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; @@ -133,9 +134,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/options/AgentOptionsTest.java b/agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java index e35e053f5..203434dfb 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 @@ -447,7 +447,7 @@ private static AgentOptions parseProxyOptions(String otherOptionsString, ProxySy 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) { + if (passwordFile != null) { String proxyPasswordFileOption = String.format("proxy-password-file=%s", passwordFile.getAbsoluteFile()); optionsString += "," + proxyPasswordFileOption; } @@ -465,11 +465,7 @@ private void assertTeamscaleProxySystemPropertiesAreCorrect(ProxySystemPropertie } private void clearTeamscaleProxySystemProperties(ProxySystemProperties.Protocol protocol) { - TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol); - teamscaleProxySystemProperties.setProxyHost(""); - teamscaleProxySystemProperties.setProxyPort(""); - teamscaleProxySystemProperties.setProxyUser(""); - teamscaleProxySystemProperties.setProxyPassword(""); + new TeamscaleProxySystemProperties(protocol).clear(); } /** Returns the include filter predicate for the given filter expression. */ private static Predicate includeFilter(String filterString) throws Exception { 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 index 44cf2e411..e1fb23b99 100644 --- a/agent/src/test/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptionsTest.java +++ b/agent/src/test/java/com/teamscale/jacoco/agent/options/TeamscaleProxyOptionsTest.java @@ -26,9 +26,6 @@ void testTeamscaleProxyOptionsFilledWithJVMOptionsOnInit() { assertThat(teamscaleProxyOptions.proxyUser).isEqualTo(expectedUser); assertThat(teamscaleProxyOptions.proxyPassword).isEqualTo(expectedPassword); - proxySystemProperties.setProxyHost(""); - proxySystemProperties.setProxyPort(""); - proxySystemProperties.setProxyUser(""); - proxySystemProperties.setProxyPassword(""); + proxySystemProperties.clear(); } } \ No newline at end of file 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 395adbec9..2daccefaf 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 @@ -86,9 +86,9 @@ public void testAccessViaTiaClientAndReportUploadToTeamscale() throws Exception 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); 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/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java b/report-generator/src/main/java/com/teamscale/report/jacoco/OpenAnalyzer.java index 7e679b05b..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 @@ -54,6 +54,8 @@ * {@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 { 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 f91e52202..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 @@ -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 { diff --git a/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt b/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt index 7e1ebc962..808e949a4 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/ReportUtils.kt @@ -8,7 +8,6 @@ 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.FileFilter import java.io.IOException import java.util.* @@ -39,7 +38,7 @@ object ReportUtils { @Throws(JsonProcessingException::class) fun getTestwiseCoverageReportAsString( report: TestwiseCoverageReport - ): String = JsonUtils.serialize(report) + ) = JsonUtils.serialize(report) /** Writes the report object to the given file as json. */ @Throws(IOException::class) @@ -59,7 +58,7 @@ object ReportUtils { clazz: Class>, directoriesOrFiles: List ) = listFiles(format, directoriesOrFiles) - .mapNotNull { JsonUtils.deserializeFile(it, clazz) } + .map { JsonUtils.deserializeFile(it, clazz) } .flatMap { listOf(*it) } /** Recursively lists all files of the given artifact type. */ @@ -70,14 +69,14 @@ object ReportUtils { ) = directoriesOrFiles.flatMap { directoryOrFile -> when { directoryOrFile.isDirectory() -> { - FileSystemUtils.listFilesRecursively(directoryOrFile) { - it.isOfArtifactFormat(format) - } + directoryOrFile.walkTopDown().filter { it.isOfArtifactFormat(format) }.toList() } + directoryOrFile.isOfArtifactFormat(format) -> { listOf(directoryOrFile) } - else -> emptyList() + + else -> emptyList() } } 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 index b8da73938..ea3406698 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/JaCoCoXmlReportGenerator.kt @@ -47,8 +47,7 @@ class JaCoCoXmlReportGenerator( analyzeStructureAndAnnotateCoverage(mergedStore).apply { checkForEmptyReport() coverageFile.outputStream.use { outputStream -> - createReport( - outputStream, this, dump.info, mergedStore) + createReport(outputStream, this, dump.info, mergedStore) } } } 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 index d049df8dd..2f7c8304b 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/jacoco/TeamscaleCoverageBuilder.kt @@ -45,13 +45,15 @@ internal class TeamscaleCoverageBuilder( // 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.") + 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 } 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 index b7eb6a329..181e0b668 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/TestwiseCoverageReportWriter.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/TestwiseCoverageReportWriter.kt @@ -3,13 +3,11 @@ 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 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 index 735ef1b1e..a4609b9d6 100644 --- 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 @@ -45,9 +45,13 @@ open class CachingExecutionDataReader( */ 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) } + .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) /** @@ -73,7 +77,7 @@ open class CachingExecutionDataReader( /** * 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. 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 index bfa8df949..3b6b77844 100644 --- 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 @@ -87,7 +87,9 @@ open class JaCoCoTestwiseReportGenerator( } /** Collects execution information per session and passes it to the consumer . */ - private class DumpCallback(private val consumer: DumpConsumer) : IExecutionDataVisitor, ISessionInfoVisitor { + private class DumpCallback( + private val consumer: DumpConsumer + ) : IExecutionDataVisitor, ISessionInfoVisitor { /** The dump that is currently being read. */ private var currentDump: Dump? = null diff --git a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt index 6ad52eee1..bb718aaff 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/testwise/jacoco/cache/AnalyzerCache.kt @@ -9,8 +9,6 @@ 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 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 index ac01714f2..034e78548 100644 --- 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 @@ -41,6 +41,7 @@ class ClassCoverageLookup internal constructor( 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 @@ -60,6 +61,7 @@ class ClassCoverageLookup internal constructor( coveredLines.isEmpty() -> logger.debug( "$sourceFileName $className contains a method with no line information. Does the class contain debug information?" ) + else -> fileCoverage.addLines(coveredLines) } } else { 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 index ca73fb3e6..072433ad4 100644 --- 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 @@ -2,9 +2,7 @@ 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.function.Function /** Container for coverage produced by multiple tests. */ class TestwiseCoverageReportBuilder { 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 index 2ba781fd5..0d89e67e8 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/AntPatternIncludeFilter.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/AntPatternIncludeFilter.kt @@ -9,7 +9,6 @@ import com.teamscale.client.AntPatternUtils import com.teamscale.client.FileSystemUtils import java.util.function.Predicate import java.util.regex.Pattern -import java.util.stream.Collectors /** * Applies ANT include and exclude patterns to paths. 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 index e2f6be389..ba9834456 100644 --- a/report-generator/src/main/kotlin/com/teamscale/report/util/BashFileSkippingInputStream.kt +++ b/report-generator/src/main/kotlin/com/teamscale/report/util/BashFileSkippingInputStream.kt @@ -57,7 +57,7 @@ class BashFileSkippingInputStream(input: InputStream) : FilterInputStream(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) + (0..length - ZIP_HEADER.size) .firstOrNull { buffer[it] == ZIP_HEADER[0] && buffer[it + 1] == ZIP_HEADER[1] 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/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 fee858ff7..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/CommitDescriptor.java +++ /dev/null @@ -1,65 +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 == null) { - return null; - } - 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 1e4ab08bc..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/HttpUtils.java +++ /dev/null @@ -1,228 +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) { - TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties(protocol); - try { - if (!teamscaleProxySystemProperties.proxyServerIsSet()) { - return false; - } - - useProxyServer(httpClientBuilder, teamscaleProxySystemProperties.getProxyHost(), - teamscaleProxySystemProperties.getProxyPort()); - - } catch (ProxySystemProperties.IncorrectPortFormatException e) - { - LOGGER.warn(e.getMessage()); - return false; - } - - if (teamscaleProxySystemProperties.proxyAuthIsSet()) { - useProxyAuthenticator(httpClientBuilder, teamscaleProxySystemProperties.getProxyUser(), teamscaleProxySystemProperties.getProxyPassword()); - } - - return true; - } - - 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 565966167..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ITeamscaleService.java +++ /dev/null @@ -1,216 +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/v9.4.0/running-profilers") - Call registerProfiler( - @Query("configuration-id") String configurationId, - @Body ProcessInformation processInformation - ); - - /** Updates the profiler infos and sets the profiler to still alive. */ - @PUT("api/v9.4.0/running-profilers/{profilerId}") - Call sendHeartbeat( - @Path("profilerId") String profilerId, - @Body ProfilerInfo profilerInfo - ); - - /** Removes the profiler identified by given ID. */ - @DELETE("api/v9.4.0/running-profilers/{profilerId}") - Call unregisterProfiler(@Path("profilerId") String profilerId); - - /** - * 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/ProxySystemProperties.java b/teamscale-client/src/main/java/com/teamscale/client/ProxySystemProperties.java deleted file mode 100644 index a327069ec..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/ProxySystemProperties.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.teamscale.client; - -import org.jetbrains.annotations.NotNull; - -/** - * 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 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(); - } - } - - /** - * @return a prefix to the system properties. Used in {@link TeamscaleProxySystemProperties} to differentiate them - * from the JVM system properties for proxies. - * */ - @NotNull - protected String getPropertyPrefix() { - return ""; - } - - /** - * @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() throws IncorrectPortFormatException { - return !StringUtils.isEmpty(getProxyHost()) && getProxyPort() > 0; - } - - /** Checks whether proxyUser and proxyPassword are set */ - public boolean proxyAuthIsSet() { - return !StringUtils.isEmpty(getProxyUser()) && !StringUtils.isEmpty(getProxyPassword()); - } - - /** @return the http(s).proxyHost system variable */ - public String getProxyHost() { - return System.getProperty(getProxyHostSystemPropertyName()); - } - - /** @return the http(s).proxyPort system variable. Returns -1 if no or an invalid port was set. */ - public int getProxyPort() throws IncorrectPortFormatException { - return parsePort(System.getProperty(getProxyPortSystemPropertyName())); - } - - /** Set the http(s).proxyHost system variable. */ - public void setProxyHost(String proxyHost) { - System.setProperty(getProxyHostSystemPropertyName(), proxyHost); - } - - /** @return the name of the system property specifying the proxy host. */ - @NotNull - protected String getProxyHostSystemPropertyName() { - return getPropertyPrefix() + 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()); - } - - /** @return the name of the system property specifying the proxy port. */ - @NotNull - protected String getProxyPortSystemPropertyName() { - return getPropertyPrefix() + protocol + PROXY_PORT_SYSTEM_PROPERTY; - } - - /** @return 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); - } - - /** @return the name of the system property specifying the proxy user. */ - @NotNull - protected String getProxyUserSystemPropertyName() { - return getPropertyPrefix() + protocol + PROXY_USER_SYSTEM_PROPERTY; - } - - /** @return 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); - } - - /** @return the name of the system property specifying the proxy password. */ - @NotNull - protected String getProxyPasswordSystemPropertyName() { - return getPropertyPrefix() + protocol + PROXY_PASSWORD_SYSTEM_PROPERTY; - } - - /** Exception thrown if the port is in an unknown format and cannot be read from the system properties. */ - public static class IncorrectPortFormatException extends IllegalArgumentException { - - IncorrectPortFormatException(String message, Throwable cause) { - super(message, cause); - } - } - - /** Parses the given port string. Returns -1 if the string is null or not a valid number. */ - private int parsePort(String portString) throws IncorrectPortFormatException { - if (StringUtils.isEmpty(portString)) { - return -1; - } - - try { - return Integer.parseInt(portString); - } catch (NumberFormatException e) { - throw new IncorrectPortFormatException("Could not parse proxy port \"" + portString + - "\" set via \"" + getProxyPortSystemPropertyName() + "\"", e); - } - } -} 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/TeamscaleProxySystemProperties.java b/teamscale-client/src/main/java/com/teamscale/client/TeamscaleProxySystemProperties.java deleted file mode 100644 index 94245b9b7..000000000 --- a/teamscale-client/src/main/java/com/teamscale/client/TeamscaleProxySystemProperties.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.teamscale.client; - -import org.jetbrains.annotations.NotNull; - -/** - * 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 {@link ProxySystemProperties}). - */ -public class TeamscaleProxySystemProperties extends ProxySystemProperties { - - private static final String TEAMSCALE_PREFIX = "teamscale."; - - /** @see ProxySystemProperties#ProxySystemProperties */ - public TeamscaleProxySystemProperties(Protocol protocol) { - super(protocol); - } - - @Override - @NotNull - protected String getPropertyPrefix() { - return TEAMSCALE_PREFIX; - } -} \ No newline at end of file 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..e8c3fac0b --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ClusteredTestDetails.kt @@ -0,0 +1,46 @@ +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( + @JsonProperty("uniformPath") uniformPath: String, + @JsonProperty("sourcePath") sourcePath: String?, + @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) { + companion object { + /** + * Creates clustered test details with the given additional [TestData]. + * + * + * Use this to easily mark additional files or data as belonging to that test case. Whenever the given + * [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. + */ + fun createWithTestData( + uniformPath: String, sourcePath: String, testData: TestData, + clusterId: String, partition: String + ) = ClusteredTestDetails(uniformPath, sourcePath, testData.hash, clusterId, partition) + } +} + 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..b304932e4 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt @@ -0,0 +1,171 @@ +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. */ + @POST("api/v9.4.0/running-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/v9.4.0/running-profilers/{profilerId}") + fun sendHeartbeat( + @Path("profilerId") profilerId: String, + @Body profilerInfo: ProfilerInfo + ): Call + + /** Removes the profiler identified by the given ID. */ + @DELETE("api/v9.4.0/running-profilers/{profilerId}") + fun unregisterProfiler(@Path("profilerId") profilerId: String): 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/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..acfd4f29b --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ProxySystemProperties.kt @@ -0,0 +1,78 @@ +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 class Protocol { + HTTP, HTTPS; + + override fun toString() = name.lowercase() + } + + protected open val propertyPrefix = "" + + var proxyHost: String? + get() = getProperty(PROXY_HOST_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_HOST_SYSTEM_PROPERTY, value) + } + + 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()) + } + + var proxyUser: String? + get() = getProperty(PROXY_USER_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_USER_SYSTEM_PROPERTY, value) + } + + var proxyPassword: String? + get() = getProperty(PROXY_PASSWORD_SYSTEM_PROPERTY) + set(value) { + setProperty(PROXY_PASSWORD_SYSTEM_PROPERTY, value) + } + + private fun getProperty(property: String) = + System.getProperty("$propertyPrefix${protocol}.$property") + + private fun setProperty(property: String, value: String?) { + value?.let { + check(it.isNotBlank()) { "Value must not be blank" } + System.setProperty("$propertyPrefix${protocol}.$property", it) + } + } + + fun isProxyServerSet() = !proxyHost.isNullOrEmpty() && proxyPort > 0 + + fun isProxyAuthSet() = !proxyUser.isNullOrEmpty() && !proxyPassword.isNullOrEmpty() + + 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") + } + + class IncorrectPortFormatException(message: String, cause: Throwable) : IllegalArgumentException(message, cause) +} 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..9eb7d1a20 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -0,0 +1,197 @@ +/*-------------------------------------------------------------------------+ +| | +| 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..2eb813c10 --- /dev/null +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/TeamscaleProxySystemProperties.kt @@ -0,0 +1,22 @@ +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) { + override val propertyPrefix: String + get() = TEAMSCALE_PREFIX + + companion object { + const val TEAMSCALE_PREFIX = "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..a79a6e99b --- /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 + + var message: String? = null + /** + * The commit message shown in the Teamscale UI for the coverage upload. If the message is null, auto-generates a + * sensible message. + */ + 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") + } + } + + val isConfiguredForSingleProjectTeamscaleUpload: Boolean + /** Checks if all fields required for a single-project Teamscale upload are non-null. */ + get() = isConfiguredForServerConnection && partition != null && project != null + + val isConfiguredForMultiProjectUpload: Boolean + /** Checks if all fields required for a Teamscale upload are non-null, except the project which must be null. */ + get() = isConfiguredForServerConnection && partition != null && project == null + + val isConfiguredForServerConnection: Boolean + /** Checks if all required fields to access a Teamscale server are non-null. */ + 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 29eb83cd3..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/ProxySystemPropertiesTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.teamscale.client; - -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ProxySystemPropertiesTest { - - private static final ProxySystemProperties properties = new ProxySystemProperties(ProxySystemProperties.Protocol.HTTP); - - @AfterAll - static void teardown() { - properties.removeProxyPort(); - } - - @Test - void testPortParsing() { - properties.setProxyPort(9876); - assertThat(properties.getProxyPort()).isEqualTo(9876); - properties.setProxyPort(""); - assertThat(properties.getProxyPort()).isEqualTo(-1); - String incorrectFormatValue = "nonsense"; - properties.setProxyPort(incorrectFormatValue); - ProxySystemProperties.IncorrectPortFormatException exception = assertThrows(ProxySystemProperties.IncorrectPortFormatException.class, - properties::getProxyPort); - assertThat(exception.getMessage()).isEqualTo(String.format("Could not parse proxy port \"%s\" set via \"%s\"", incorrectFormatValue, properties.getProxyPortSystemPropertyName())); - properties.removeProxyPort(); - 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 e4e19aa3a..000000000 --- a/teamscale-client/src/test/java/com/teamscale/client/TeamscaleServiceGeneratorProxyServerTest.java +++ /dev/null @@ -1,95 +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); - - private final TeamscaleProxySystemProperties teamscaleProxySystemProperties = new TeamscaleProxySystemProperties( - ProxySystemProperties.Protocol.HTTP); - - @BeforeEach - void setUp() throws IOException { - mockProxyServer = new MockWebServer(); - mockProxyServer.start(); - } - - - @Test - void testTeamscaleProxyAuthentication() throws Exception { - String incorrectValue = "incorrect"; - // the teamscale-specific options should take precedence over the global ones - proxySystemProperties.setProxyHost(incorrectValue); - proxySystemProperties.setProxyPort(incorrectValue); - proxySystemProperties.setProxyUser(incorrectValue); - proxySystemProperties.setProxyPassword(incorrectValue); - - teamscaleProxySystemProperties.setProxyHost(mockProxyServer.getHostName()); - teamscaleProxySystemProperties.setProxyPort(mockProxyServer.getPort()); - - String proxyUser = "myProxyUser"; - String proxyPassword = "myProxyPassword"; - String base64EncodedBasicAuth = Base64.getEncoder().encodeToString((proxyUser + ":" + proxyPassword).getBytes( - StandardCharsets.UTF_8)); - teamscaleProxySystemProperties.setProxyUser(proxyUser); - teamscaleProxySystemProperties.setProxyPassword(proxyPassword); - - assertProxyAuthenticationIsUsed(base64EncodedBasicAuth); - } - - private void assertProxyAuthenticationIsUsed(String base64EncodedBasicAuth) throws InterruptedException, IOException { - 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 { - clearProxySystemProperties(proxySystemProperties); - clearProxySystemProperties(teamscaleProxySystemProperties); - - mockProxyServer.shutdown(); - mockProxyServer.close(); - } - - private void clearProxySystemProperties(ProxySystemProperties proxySystemProperties) { - proxySystemProperties.setProxyHost(""); - proxySystemProperties.setProxyPort(""); - proxySystemProperties.setProxyUser(""); - proxySystemProperties.setProxyPassword(""); - } -} \ 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..9a610a494 100755 --- a/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt +++ b/teamscale-gradle-plugin/src/main/kotlin/com/teamscale/TeamscaleUploadTask.kt @@ -102,11 +102,14 @@ abstract class TeamscaleUploadTask : DefaultTask() { try { // Prefer to upload to revision and fallback to branch timestamp - val commitDescriptorOrNull = if (revision != null) null else commitDescriptor!! + val commitDescriptorOrNull = if (revision != null) null else commitDescriptor retry(3) { - val client = - TeamscaleClient(server.url, server.userName, server.userAccessToken, server.project) - client.uploadReports( + TeamscaleClient( + server.url, + server.userName!!, + server.userAccessToken!!, + server.project!! + ).uploadReports( format, reportFiles, commitDescriptorOrNull, @@ -127,7 +130,7 @@ abstract class TeamscaleUploadTask : DefaultTask() { /** * 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/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..dfe2df485 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 @@ -3,24 +3,16 @@ package com.teamscale.config 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!") 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/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) + } +}