From f013cca7baa27b6746556c8f324ace58a4954590 Mon Sep 17 00:00:00 2001 From: Constructor Date: Mon, 13 Jan 2025 23:04:08 +0100 Subject: [PATCH] TS-38628 TiaMojo migration and lateinit approach --- .../maven/tia/TiaIntegrationTestMojo.java | 40 -- .../com/teamscale/maven/tia/TiaMojoBase.java | 447 ------------------ .../teamscale/maven/tia/TiaUnitTestMojo.java | 40 -- .../com/teamscale/maven/TeamscaleMojoBase.kt | 27 +- .../maven/tia/TiaIntegrationTestMojo.kt | 27 ++ .../com/teamscale/maven/tia/TiaMojoBase.kt | 446 +++++++++++++++++ .../teamscale/maven/tia/TiaUnitTestMojo.kt | 27 ++ 7 files changed, 510 insertions(+), 544 deletions(-) delete mode 100644 teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaIntegrationTestMojo.java delete mode 100644 teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java delete mode 100644 teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaUnitTestMojo.java create mode 100644 teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaIntegrationTestMojo.kt create mode 100644 teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaMojoBase.kt create mode 100644 teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaUnitTestMojo.kt diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaIntegrationTestMojo.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaIntegrationTestMojo.java deleted file mode 100644 index f280c0456..000000000 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaIntegrationTestMojo.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.teamscale.maven.tia; - -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.plugins.annotations.ResolutionScope; - -/** - * Instruments the Failsafe integration tests and uploads testwise coverage to Teamscale. - */ -@Mojo(name = "prepare-tia-integration-test", defaultPhase = LifecyclePhase.PACKAGE, - requiresDependencyResolution = ResolutionScope.RUNTIME, threadSafe = true) -public class TiaIntegrationTestMojo extends TiaMojoBase { - - /** - * The partition to which to upload integration test coverage. - */ - @Parameter(defaultValue = "Integration Tests") - public String partition; - - @Override - protected String getPartition() { - return partition; - } - - @Override - protected boolean isIntegrationTest() { - return true; - } - - @Override - protected String getTestPluginArtifact() { - return "org.apache.maven.plugins:maven-failsafe-plugin"; - } - - @Override - protected String getTestPluginPropertyPrefix() { - return "failsafe"; - } -} diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java deleted file mode 100644 index 23be7bbf0..000000000 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaMojoBase.java +++ /dev/null @@ -1,447 +0,0 @@ -package com.teamscale.maven.tia; - -import com.teamscale.maven.TeamscaleMojoBase; -import org.apache.commons.lang3.ArrayUtils; -import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.util.Strings; -import org.apache.maven.artifact.Artifact; -import org.apache.maven.model.Plugin; -import org.apache.maven.model.PluginExecution; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.Parameter; -import org.codehaus.plexus.util.xml.Xpp3Dom; -import org.conqat.lib.commons.filesystem.FileSystemUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.ServerSocket; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.Map; -import java.util.Properties; - -/** - * Base class for TIA Mojos. Provides all necessary functionality but can be subclassed to change the partition. - *

- * For this plugin to work, you must either - * - *

- *

- * To use our JUnit 5 impacted-test-engine, you must declare it as a test dependency. Example: - * - *

{@code
- * 
- * 
- * com.teamscale
- * impacted-test-engine
- * 30.0.0
- * test
- * 
- * 
- * }
- *

- * To send test events yourself, you can use our TIA client library (Maven coordinates: com.teamscale:tia-client). - *

- * The log file of the agent is written to {@code ${project.build.directory}/tia/agent.log}. - */ -public abstract class TiaMojoBase extends TeamscaleMojoBase { - - /** - * Name of the surefire/failsafe option to pass in - * included - * engines - */ - private static final String INCLUDE_JUNIT5_ENGINES_OPTION = "includeJUnit5Engines"; - - /** - * Name of the surefire/failsafe option to pass in - * excluded - * engines - */ - private static final String EXCLUDE_JUNIT5_ENGINES_OPTION = "excludeJUnit5Engines"; - - /** - * Impacted tests are calculated from "baselineCommit" to "commit". This sets the baseline. - */ - @Parameter - public String baselineCommit; - - /** - * Impacted tests are calculated from "baselineCommit" to "commit". - * The baselineRevision sets the baselineCommit with the help of a VCS revision (e.g. git SHA1) instead of a branch and timestamp - */ - @Parameter - public String baselineRevision; - - /** - * You can optionally specify which code should be included in the coverage instrumentation. Each pattern is applied - * to the fully qualified class names of the profiled system. Use {@code *} to match any number characters and - * {@code ?} to match any single character. - *

- * Classes that match any of the include patterns are included, unless any exclude pattern excludes them. - */ - @Parameter - public String[] includes; - - /** - * You can optionally specify which code should be excluded from the coverage instrumentation. Each pattern is - * applied to the fully qualified class names of the profiled system. Use {@code *} to match any number characters - * and {@code ?} to match any single character. - *

- * Classes that match any of the exclude patterns are excluded, even if they are included by an include pattern. - */ - @Parameter - public String[] excludes; - - /** - * In order to instrument the system under test, a Java agent must be attached to the JVM of the system. The JVM - * command line arguments to achieve this are by default written to the property {@code argLine}, which is - * automatically picked up by Surefire and Failsafe and applied to the JVMs these plugins start. You can override - * the name of this property if you wish to manually apply the command line arguments yourself, e.g. if your system - * under test is started by some other plugin like the Spring boot starter. - */ - @Parameter - public String propertyName; - - /** - * Port on which the Java agent listens for commands from this plugin. The default value 0 will tell the agent to - * automatically search for an open port. - */ - @Parameter(defaultValue = "0") - public String agentPort; - - /** - * Optional additional arguments to send to the agent. Each argument must be of the form {@code KEY=VALUE}. - */ - @Parameter - public String[] additionalAgentOptions; - - - /** - * Changes the log level of the agent to DEBUG. - */ - @Parameter(defaultValue = "false") - public boolean debugLogging; - - /** - * Executes all tests, not only impacted ones if set. Defaults to false. - */ - @Parameter(defaultValue = "false") - public boolean runAllTests; - - /** - * Executes only impacted tests, not all ones if set. Defaults to true. - */ - @Parameter(defaultValue = "true") - public boolean runImpacted; - - /** - * Mode of producing testwise coverage. - */ - @Parameter(defaultValue = "teamscale-upload") - public String tiaMode; - - /** - * Map of resolved Maven artifacts. Provided automatically by Maven. - */ - @Parameter(property = "plugin.artifactMap", required = true, readonly = true) - public Map pluginArtifactMap; - - /** - * The project build directory (usually: {@code ./target}). Provided automatically by Maven. - */ - @Parameter(defaultValue = "${project.build.directory}") - public String projectBuildDir; - - private Path targetDirectory; - - @Override - public void execute() throws MojoFailureException, MojoExecutionException { - super.execute(); - - if (StringUtils.isNotEmpty(baselineCommit) && StringUtils.isNotEmpty(baselineRevision)) { - getLog().warn("Both baselineRevision and baselineCommit are set but only one of them is needed. " + - "The revision will be preferred in this case. If that's not intended, please do not set the baselineRevision manually."); - } - - if (skip) { - return; - } - - Plugin testPlugin = getTestPlugin(getTestPluginArtifact()); - if (testPlugin != null) { - configureTestPlugin(); - for (PluginExecution execution : testPlugin.getExecutions()) { - validateTestPluginConfiguration(execution); - } - } - - targetDirectory = Paths.get(projectBuildDir, "tia").toAbsolutePath(); - createTargetDirectory(); - - resolveCommitOrRevision(); - - setTiaProperties(); - - Path agentConfigFile = createAgentConfigFiles(agentPort); - Path logFilePath = targetDirectory.resolve("agent.log"); - setArgLine(agentConfigFile, logFilePath); - } - - private void setTiaProperties() { - setTiaProperty("reportDirectory", targetDirectory.toString()); - setTiaProperty("server.url", teamscaleUrl); - setTiaProperty("server.project", projectId); - setTiaProperty("server.userName", username); - setTiaProperty("server.userAccessToken", accessToken); - - if (StringUtils.isNotEmpty(resolvedRevision)) { - setTiaProperty("endRevision", resolvedRevision); - } else { - setTiaProperty("endCommit", resolvedCommit); - } - - if (StringUtils.isNotEmpty(baselineRevision)) { - setTiaProperty("baselineRevision", baselineRevision); - } else { - setTiaProperty("baseline", baselineCommit); - } - - setTiaProperty("repository", repository); - setTiaProperty("partition", getPartition()); - if (agentPort.equals("0")) { - agentPort = findAvailablePort(); - } - - setTiaProperty("agentsUrls", "http://localhost:" + agentPort); - setTiaProperty("runImpacted", Boolean.valueOf(runImpacted).toString()); - setTiaProperty("runAllTests", Boolean.valueOf(runAllTests).toString()); - } - - /** - * Automatically find an available port. - */ - private String findAvailablePort() { - try (ServerSocket socket = new ServerSocket(0)) { - int port = socket.getLocalPort(); - getLog().info("Automatically set server port to " + port); - return String.valueOf(port); - } catch (IOException e) { - getLog().error("Port blocked, trying again.", e); - return findAvailablePort(); - } - } - - /** - * Sets the teamscale-test-impacted engine as only includedEngine and passes all previous engine configuration to - * the impacted test engine instead. - */ - private void configureTestPlugin() { - enforcePropertyValue(INCLUDE_JUNIT5_ENGINES_OPTION, "includedEngines", "teamscale-test-impacted"); - enforcePropertyValue(EXCLUDE_JUNIT5_ENGINES_OPTION, "excludedEngines", ""); - } - - private void enforcePropertyValue(String engineOption, String impactedEngineSuffix, - String newValue) { - overrideProperty(engineOption, impactedEngineSuffix, newValue, session.getCurrentProject().getProperties()); - overrideProperty(engineOption, impactedEngineSuffix, newValue, session.getUserProperties()); - } - - private void overrideProperty(String engineOption, String impactedEngineSuffix, String newValue, - Properties properties) { - Object originalValue = properties.put(getPropertyName(engineOption), newValue); - if (originalValue instanceof String && !Strings.isBlank((String) originalValue) && !newValue.equals( - originalValue)) { - setTiaProperty(impactedEngineSuffix, (String) originalValue); - } - } - - private void validateTestPluginConfiguration(PluginExecution execution) throws MojoFailureException { - Xpp3Dom configurationDom = (Xpp3Dom) execution.getConfiguration(); - if (configurationDom == null) { - return; - } - - validateEngineNotConfigured(configurationDom, INCLUDE_JUNIT5_ENGINES_OPTION); - validateEngineNotConfigured(configurationDom, EXCLUDE_JUNIT5_ENGINES_OPTION); - - validateParallelizationParameter(configurationDom, "threadCount"); - validateParallelizationParameter(configurationDom, "forkCount"); - - Xpp3Dom parameterDom = configurationDom.getChild("reuseForks"); - if (parameterDom == null) { - return; - } - String value = parameterDom.getValue(); - if (value != null && !value.equals("true")) { - getLog().warn( - "You configured surefire to not reuse forks." + - " This has been shown to lead to performance decreases in combination with the Teamscale Maven Plugin." + - " If you notice performance problems, please have a look at our troubleshooting section for possible solutions: https://docs.teamscale.com/howto/providing-testwise-coverage/#troubleshooting."); - } - } - - private void validateEngineNotConfigured(Xpp3Dom configurationDom, - String xmlConfigurationName) throws MojoFailureException { - Xpp3Dom engines = configurationDom.getChild(xmlConfigurationName); - if (engines != null) { - throw new MojoFailureException( - "You configured JUnit 5 engines in the " + getTestPluginArtifact() + " plugin via the " + xmlConfigurationName + " configuration parameter." + - " This is currently not supported when performing Test Impact analysis." + - " Please add the " + xmlConfigurationName + " via the " + getPropertyName( - xmlConfigurationName) + " property."); - } - } - - @NotNull - private String getPropertyName(String xmlConfigurationName) { - return getTestPluginPropertyPrefix() + "." + xmlConfigurationName; - } - - @Nullable - private Plugin getTestPlugin(String testPluginArtifact) { - Map plugins = session.getCurrentProject().getModel().getBuild().getPluginsAsMap(); - return plugins.get(testPluginArtifact); - } - - private void validateParallelizationParameter(Xpp3Dom configurationDom, - String parallelizationParameter) throws MojoFailureException { - Xpp3Dom parameterDom = configurationDom.getChild(parallelizationParameter); - if (parameterDom == null) { - return; - } - - String value = parameterDom.getValue(); - if (value != null && !value.equals("1")) { - throw new MojoFailureException( - "You configured parallel tests in the " + getTestPluginArtifact() + " plugin via the " + parallelizationParameter + " configuration parameter." + - " Parallel tests are not supported when performing Test Impact analysis as they prevent recording testwise coverage." + - " Please disable parallel tests when running Test Impact analysis."); - } - } - - /** - * @return the partition to upload testwise coverage to. - */ - protected abstract String getPartition(); - - /** - * @return the artifact name of the test plugin (e.g. Surefire, Failsafe). - */ - protected abstract String getTestPluginArtifact(); - - /** @return The prefix of the properties that are used to pass parameters to the plugin. */ - protected abstract String getTestPluginPropertyPrefix(); - - /** - * @return whether this Mojo applies to integration tests. - *

- * Depending on this, different properties are used to set the argLine. - */ - protected abstract boolean isIntegrationTest(); - - private void createTargetDirectory() throws MojoFailureException { - try { - Files.createDirectories(targetDirectory); - } catch (IOException e) { - throw new MojoFailureException("Could not create target directory " + targetDirectory, e); - } - } - - private void setArgLine(Path agentConfigFile, Path logFilePath) { - String agentLogLevel = "INFO"; - if (debugLogging) { - agentLogLevel = "DEBUG"; - } - - ArgLine.cleanOldArgLines(session, getLog()); - ArgLine.applyToMavenProject( - new ArgLine(additionalAgentOptions, agentLogLevel, findAgentJarFile(), agentConfigFile, logFilePath), - session, getLog(), propertyName, isIntegrationTest()); - } - - private Path createAgentConfigFiles(String agentPort) throws MojoFailureException { - Path loggingConfigPath = targetDirectory.resolve("logback.xml"); - try (OutputStream loggingConfigOutputStream = Files.newOutputStream(loggingConfigPath)) { - FileSystemUtils.copy(readAgentLogbackConfig(), loggingConfigOutputStream); - } catch (IOException e) { - throw new MojoFailureException("Writing the logging configuration file for the TIA agent failed." + - " Make sure the path " + loggingConfigPath + " is writeable.", e); - } - - Path configFilePath = targetDirectory.resolve("agent-at-port-" + agentPort + ".properties"); - String agentConfig = createAgentConfig(loggingConfigPath, targetDirectory.resolve("reports")); - try { - Files.write(configFilePath, Collections.singleton(agentConfig)); - } catch (IOException e) { - throw new MojoFailureException("Writing the configuration file for the TIA agent failed." + - " Make sure the path " + configFilePath + " is writeable.", e); - } - - getLog().info("Agent config file created at " + configFilePath); - return configFilePath; - } - - private InputStream readAgentLogbackConfig() { - return TiaMojoBase.class.getResourceAsStream("logback-agent.xml"); - } - - private String createAgentConfig(Path loggingConfigPath, Path agentOutputDirectory) { - String config = "mode=testwise" + - "\ntia-mode=" + tiaMode + - "\nteamscale-server-url=" + teamscaleUrl + - "\nteamscale-project=" + projectId + - "\nteamscale-user=" + username + - "\nteamscale-access-token=" + accessToken + - "\nteamscale-partition=" + getPartition() + - "\nhttp-server-port=" + agentPort + - "\nlogging-config=" + loggingConfigPath + - "\nout=" + agentOutputDirectory.toAbsolutePath(); - if (ArrayUtils.isNotEmpty(includes)) { - config += "\nincludes=" + String.join(";", includes); - } - if (ArrayUtils.isNotEmpty(excludes)) { - config += "\nexcludes=" + String.join(";", excludes); - } - if (StringUtils.isNotBlank(repository)) { - config += "\nteamscale-repository=" + repository; - } - - if (StringUtils.isNotEmpty(resolvedRevision)) { - config += "\nteamscale-revision=" + resolvedRevision; - } else { - config += "\nteamscale-commit=" + resolvedCommit; - } - return config; - } - - private Path findAgentJarFile() { - Artifact agentArtifact = pluginArtifactMap.get("com.teamscale:teamscale-jacoco-agent"); - return agentArtifact.getFile().toPath(); - } - - /** - * Sets a property in the TIA namespace. It seems that, depending on Maven version and which other plugins are used, - * different types of properties are respected both during the build and during tests (as e.g. failsafe tests are - * often run in a separate JVM spawned by Maven). So we set our properties in every possible way to make sure the - * plugin works out of the box in most situations. - */ - private void setTiaProperty(String name, String value) { - if (value != null) { - String fullyQualifiedName = "teamscale.test.impacted." + name; - getLog().debug("Setting property " + name + "=" + value); - session.getUserProperties().setProperty(fullyQualifiedName, value); - session.getSystemProperties().setProperty(fullyQualifiedName, value); - System.setProperty(fullyQualifiedName, value); - } - } -} diff --git a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaUnitTestMojo.java b/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaUnitTestMojo.java deleted file mode 100644 index e43bf160f..000000000 --- a/teamscale-maven-plugin/src/main/java/com/teamscale/maven/tia/TiaUnitTestMojo.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.teamscale.maven.tia; - -import org.apache.maven.plugins.annotations.LifecyclePhase; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.Parameter; -import org.apache.maven.plugins.annotations.ResolutionScope; - -/** - * Instruments the Surefire unit tests and uploads testwise coverage to Teamscale. - */ -@Mojo(name = "prepare-tia-unit-test", defaultPhase = LifecyclePhase.INITIALIZE, requiresDependencyResolution = ResolutionScope.RUNTIME, - threadSafe = true) -public class TiaUnitTestMojo extends TiaMojoBase { - - /** - * The partition to which to upload unit test coverage. - */ - @Parameter(defaultValue = "Unit Tests") - public String partition; - - @Override - protected String getPartition() { - return partition; - } - - @Override - protected boolean isIntegrationTest() { - return false; - } - - @Override - protected String getTestPluginArtifact() { - return "org.apache.maven.plugins:maven-surefire-plugin"; - } - - @Override - protected String getTestPluginPropertyPrefix() { - return "surefire"; - } -} diff --git a/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/TeamscaleMojoBase.kt b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/TeamscaleMojoBase.kt index 958016b09..bdae35099 100644 --- a/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/TeamscaleMojoBase.kt +++ b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/TeamscaleMojoBase.kt @@ -8,7 +8,6 @@ import org.apache.maven.plugins.annotations.Parameter import org.apache.maven.project.MavenProject import org.codehaus.plexus.util.xml.Xpp3Dom import java.io.IOException -import java.nio.file.Path /** * A base class for all Teamscale-related Maven Mojos. Offers basic attributes and functionality related to Teamscale @@ -19,31 +18,27 @@ abstract class TeamscaleMojoBase : AbstractMojo() { /** * The URL of the Teamscale instance to which the recorded coverage will be uploaded. */ - @JvmField @Parameter - var teamscaleUrl: String? = null + lateinit var teamscaleUrl: String /** * The Teamscale project to which the recorded coverage will be uploaded */ - @JvmField @Parameter - var projectId: String? = null + lateinit var projectId: String /** * The username to use to perform the upload. Must have the "Upload external data" permission for the [projectId]. * Can also be specified via the Maven property `teamscale.username`. */ - @JvmField @Parameter(property = "teamscale.username") - var username: String? = null + lateinit var username: String /** * Teamscale access token of the [username]. Can also be specified via the Maven property `teamscale.accessToken`. */ - @JvmField @Parameter(property = "teamscale.accessToken") - var accessToken: String? = null + lateinit var accessToken: String /** * You can optionally use this property to override the code commit to which the coverage will be uploaded. Format: @@ -54,7 +49,7 @@ abstract class TeamscaleMojoBase : AbstractMojo() { * the revision takes precedence. */ @Parameter(property = "teamscale.commit") - var commit: String? = null + lateinit var commit: String /** * You can optionally use this property to override the revision to which the coverage will be uploaded. If no @@ -62,17 +57,15 @@ abstract class TeamscaleMojoBase : AbstractMojo() { * specify either commit or revision, not both. If both are specified, a warning is logged and the revision takes * precedence. */ - @JvmField @Parameter(property = "teamscale.revision") - var revision: String? = null + lateinit var revision: String /** * 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 @Parameter(property = "teamscale.repository") - var repository: String? = null + lateinit var repository: String /** * Whether to skip the execution of this Mojo. @@ -101,7 +94,7 @@ abstract class TeamscaleMojoBase : AbstractMojo() { @Throws(MojoExecutionException::class, MojoFailureException::class) override fun execute() { - if (!revision.isNullOrBlank() && !commit.isNullOrBlank()) { + if (revision.isNotBlank() && commit.isNotBlank()) { log.warn( "Both revision and commit are set but only one of them is needed. " + "Teamscale will prefer the revision. If that's not intended, please do not set the revision manually." @@ -118,10 +111,10 @@ abstract class TeamscaleMojoBase : AbstractMojo() { @Throws(MojoFailureException::class) protected fun resolveCommitOrRevision() { when { - !revision.isNullOrBlank() -> { + revision.isNotBlank() -> { resolvedRevision = revision } - !commit.isNullOrBlank() -> { + commit.isNotBlank() -> { resolvedCommit = commit } else -> { diff --git a/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaIntegrationTestMojo.kt b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaIntegrationTestMojo.kt new file mode 100644 index 000000000..694f4b2ba --- /dev/null +++ b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaIntegrationTestMojo.kt @@ -0,0 +1,27 @@ +package com.teamscale.maven.tia + +import org.apache.maven.plugins.annotations.LifecyclePhase +import org.apache.maven.plugins.annotations.Mojo +import org.apache.maven.plugins.annotations.Parameter +import org.apache.maven.plugins.annotations.ResolutionScope + +/** + * Instruments the Failsafe integration tests and uploads testwise coverage to Teamscale. + */ +@Mojo( + name = "prepare-tia-integration-test", + defaultPhase = LifecyclePhase.PACKAGE, + requiresDependencyResolution = ResolutionScope.RUNTIME, + threadSafe = true +) +class TiaIntegrationTestMojo : TiaMojoBase() { + /** + * The partition to which to upload integration test coverage. + */ + @Parameter(defaultValue = "Integration Tests") + override lateinit var partition: String + + override val isIntegrationTest = true + override val testPluginArtifact = "org.apache.maven.plugins:maven-failsafe-plugin" + override val testPluginPropertyPrefix = "failsafe" +} diff --git a/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaMojoBase.kt b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaMojoBase.kt new file mode 100644 index 000000000..7dc498465 --- /dev/null +++ b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaMojoBase.kt @@ -0,0 +1,446 @@ +package com.teamscale.maven.tia + +import com.teamscale.maven.TeamscaleMojoBase +import org.apache.commons.lang3.StringUtils +import org.apache.maven.artifact.Artifact +import org.apache.maven.model.PluginExecution +import org.apache.maven.plugin.MojoExecutionException +import org.apache.maven.plugin.MojoFailureException +import org.apache.maven.plugins.annotations.Parameter +import org.codehaus.plexus.util.xml.Xpp3Dom +import org.conqat.lib.commons.filesystem.FileSystemUtils +import java.io.IOException +import java.net.ServerSocket +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* +import kotlin.io.path.createDirectories + +/** + * Base class for TIA Mojos. Provides all necessary functionality but can be subclassed to change the partition. + * + * For this plugin to work, you must either: + * - Make Surefire and Failsafe use our JUnit 5 test engine + * - Send test start and end events to the Java agent themselves + * + * To use our JUnit 5 impacted-test-engine, you must declare it as a test dependency. Example: + * + * ``` + * + * + * com.teamscale + * impacted-test-engine + * 30.0.0 + * test + * + * + * ``` + * + * To send test events yourself, you can use our TIA client library (Maven coordinates: com.teamscale:tia-client). + * The log file of the agent is written to `${project.build.directory}/tia/agent.log`. + */ +abstract class TiaMojoBase : TeamscaleMojoBase() { + /** + * Impacted tests are calculated from [baselineCommit] to [commit]. This sets the baseline. + */ + @Parameter + lateinit var baselineCommit: String + + /** + * Impacted tests are calculated from [baselineCommit] to [commit]. + * The [baselineRevision] sets the [baselineCommit] with the help of a VCS revision (e.g. git SHA1) instead of a branch and timestamp + */ + @Parameter + lateinit var baselineRevision: String + + /** + * You can optionally specify which code should be included in the coverage instrumentation. Each pattern is applied + * to the fully qualified class names of the profiled system. Use `*` to match any number characters and + * `?` to match any single character. + * + * Classes that match any of the include patterns are included, unless any exclude pattern excludes them. + */ + @Parameter + lateinit var includes: Array + + /** + * You can optionally specify which code should be excluded from the coverage instrumentation. Each pattern is + * applied to the fully qualified class names of the profiled system. Use `*` to match any number characters + * and `?` to match any single character. + * + * Classes that match any of the exclude patterns are excluded, even if they are included by an include pattern. + */ + @Parameter + lateinit var excludes: Array + + /** + * To instrument the system under test, a Java agent must be attached to the JVM of the system. The JVM + * command line arguments to achieve this are by default written to the property `argLine`, which is + * automatically picked up by Surefire and Failsafe and applied to the JVMs these plugins start. You can override + * the name of this property if you wish to manually apply the command line arguments yourself, e.g. if your system + * under test is started by some other plugin like the Spring boot starter. + */ + @Parameter + lateinit var propertyName: String + + /** + * Port on which the Java agent listens for commands from this plugin. The default value 0 will tell the agent to + * automatically search for an open port. + */ + @Parameter(defaultValue = "0") + lateinit var agentPort: String + + /** + * Optional additional arguments to send to the agent. Each argument must be of the form `KEY=VALUE`. + */ + @Parameter + lateinit var additionalAgentOptions: Array + + /** + * Changes the log level of the agent to DEBUG. + */ + @Parameter(defaultValue = "false") + var debugLogging: Boolean = false + + /** + * Executes all tests, not only impacted ones if set. Defaults to false. + */ + @Parameter(defaultValue = "false") + var runAllTests: Boolean = false + + /** + * Executes only impacted tests, not all ones if set. Defaults to true. + */ + @Parameter(defaultValue = "true") + var runImpacted: Boolean = true + + /** + * Mode of producing testwise coverage. + */ + @Parameter(defaultValue = "teamscale-upload") + lateinit var tiaMode: String + + /** + * Map of resolved Maven artifacts. Provided automatically by Maven. + */ + @Parameter(property = "plugin.artifactMap", required = true, readonly = true) + lateinit var pluginArtifactMap: Map + + /** + * The project build directory (usually: `./target`). Provided automatically by Maven. + */ + @Parameter(defaultValue = "\${project.build.directory}") + lateinit var projectBuildDir: String + + private lateinit var targetDirectory: Path + + @Throws(MojoFailureException::class, MojoExecutionException::class) + override fun execute() { + super.execute() + + if (baselineCommit.isNotBlank() && baselineRevision.isNotBlank()) { + log.warn( + "Both baselineRevision and baselineCommit are set but only one of them is needed. " + + "The revision will be preferred in this case. If that's not intended, please do not set the baselineRevision manually." + ) + } + + if (skip) return + + getTestPlugin(testPluginArtifact)?.let { testPlugin -> + configureTestPlugin() + testPlugin.executions.forEach { execution -> + validateTestPluginConfiguration(execution) + } + } + + targetDirectory = Paths.get(projectBuildDir, "tia").toAbsolutePath() + createTargetDirectory() + + resolveCommitOrRevision() + setTiaProperties() + + val agentConfigFile = createAgentConfigFiles(agentPort) + setArgLine(agentConfigFile, targetDirectory.resolve("agent.log")) + } + + private fun setTiaProperties() { + setTiaProperty("reportDirectory", targetDirectory.toString()) + setTiaProperty("server.url", teamscaleUrl) + setTiaProperty("server.project", projectId) + setTiaProperty("server.userName", username) + setTiaProperty("server.userAccessToken", accessToken) + + if (StringUtils.isNotEmpty(resolvedRevision)) { + setTiaProperty("endRevision", resolvedRevision) + } else { + setTiaProperty("endCommit", resolvedCommit) + } + + if (StringUtils.isNotEmpty(baselineRevision)) { + setTiaProperty("baselineRevision", baselineRevision) + } else { + setTiaProperty("baseline", baselineCommit) + } + + setTiaProperty("repository", repository) + setTiaProperty("partition", partition) + if (agentPort == "0") { + agentPort = findAvailablePort() + } + + setTiaProperty("agentsUrls", "http://localhost:$agentPort") + setTiaProperty("runImpacted", runImpacted.toString()) + setTiaProperty("runAllTests", runAllTests.toString()) + } + + /** + * Automatically find an available port. + */ + private fun findAvailablePort(): String { + try { + ServerSocket(0).use { socket -> + val port = socket.localPort + log.info("Automatically set server port to $port") + return port.toString() + } + } catch (e: IOException) { + log.error("Port blocked, trying again.", e) + return findAvailablePort() + } + } + + /** + * Sets the teamscale-test-impacted engine as only includedEngine and passes all previous engine configuration to + * the impacted test engine instead. + */ + private fun configureTestPlugin() { + enforcePropertyValue(INCLUDE_JUNIT5_ENGINES_OPTION, "includedEngines", "teamscale-test-impacted") + enforcePropertyValue(EXCLUDE_JUNIT5_ENGINES_OPTION, "excludedEngines", "") + } + + private fun enforcePropertyValue( + engineOption: String, + impactedEngineSuffix: String, + newValue: String + ) { + overrideProperty(engineOption, impactedEngineSuffix, newValue, session.currentProject.properties) + overrideProperty(engineOption, impactedEngineSuffix, newValue, session.userProperties) + } + + private fun overrideProperty( + engineOption: String, + impactedEngineSuffix: String, + newValue: String, + properties: Properties + ) { + (properties.put(getPropertyName(engineOption), newValue) as? String)?.let { originalValue -> + if (originalValue.isNotBlank() && (newValue != originalValue)) { + setTiaProperty(impactedEngineSuffix, originalValue) + } + } + } + + @Throws(MojoFailureException::class) + private fun validateTestPluginConfiguration(execution: PluginExecution) { + val configurationDom = execution.configuration as Xpp3Dom + + validateEngineNotConfigured(configurationDom, INCLUDE_JUNIT5_ENGINES_OPTION) + validateEngineNotConfigured(configurationDom, EXCLUDE_JUNIT5_ENGINES_OPTION) + + validateParallelizationParameter(configurationDom, "threadCount") + validateParallelizationParameter(configurationDom, "forkCount") + + val parameterDom = configurationDom.getChild("reuseForks") ?: return + val value = parameterDom.value + if (value != null && value != "true") { + log.warn( + "You configured surefire to not reuse forks." + + " This has been shown to lead to performance decreases in combination with the Teamscale Maven Plugin." + + " If you notice performance problems, please have a look at our troubleshooting section for possible solutions: https://docs.teamscale.com/howto/providing-testwise-coverage/#troubleshooting." + ) + } + } + + @Throws(MojoFailureException::class) + private fun validateEngineNotConfigured( + configurationDom: Xpp3Dom, + xmlConfigurationName: String + ) { + val engines = configurationDom.getChild(xmlConfigurationName) + if (engines != null) { + throw MojoFailureException( + "You configured JUnit 5 engines in the $testPluginArtifact plugin via the $xmlConfigurationName configuration parameter. This is currently not supported when performing Test Impact analysis. Please add the $xmlConfigurationName via the ${ + getPropertyName( + xmlConfigurationName + ) + } property." + ) + } + } + + private fun getPropertyName(xmlConfigurationName: String) = + "$testPluginPropertyPrefix.$xmlConfigurationName" + + private fun getTestPlugin(testPluginArtifact: String) = + session.currentProject.model.build.pluginsAsMap[testPluginArtifact] + + @Throws(MojoFailureException::class) + private fun validateParallelizationParameter( + configurationDom: Xpp3Dom, + parallelizationParameter: String + ) { + configurationDom.getChild(parallelizationParameter)?.value?.let { value -> + if (value == "1") return@let + throw MojoFailureException( + "You configured parallel tests in the " + testPluginArtifact + " plugin via the " + parallelizationParameter + " configuration parameter." + + " Parallel tests are not supported when performing Test Impact analysis as they prevent recording testwise coverage." + + " Please disable parallel tests when running Test Impact analysis." + ) + } + } + + /** + * @return the partition to upload testwise coverage to. + */ + protected abstract val partition: String + + /** + * @return the artifact name of the test plugin (e.g. Surefire, Failsafe). + */ + protected abstract val testPluginArtifact: String + + /** @return The prefix of the properties that are used to pass parameters to the plugin. + */ + protected abstract val testPluginPropertyPrefix: String + + /** + * @return whether this Mojo applies to integration tests. + * + * + * Depending on this, different properties are used to set the argLine. + */ + protected abstract val isIntegrationTest: Boolean + + @Throws(MojoFailureException::class) + private fun createTargetDirectory() { + try { + targetDirectory.createDirectories() + } catch (e: IOException) { + throw MojoFailureException("Could not create target directory $targetDirectory", e) + } + } + + private fun setArgLine(agentConfigFile: Path, logFilePath: Path) { + var agentLogLevel = "INFO" + if (debugLogging) { + agentLogLevel = "DEBUG" + } + + ArgLine.cleanOldArgLines(session, log) + ArgLine.applyToMavenProject( + ArgLine(additionalAgentOptions, agentLogLevel, findAgentJarFile(), agentConfigFile, logFilePath), + session, log, propertyName, isIntegrationTest + ) + } + + @Throws(MojoFailureException::class) + private fun createAgentConfigFiles(agentPort: String): Path { + val loggingConfigPath = targetDirectory.resolve("logback.xml") + try { + Files.newOutputStream(loggingConfigPath).use { loggingConfigOutputStream -> + FileSystemUtils.copy(readAgentLogbackConfig(), loggingConfigOutputStream) + } + } catch (e: IOException) { + throw MojoFailureException( + "Writing the logging configuration file for the TIA agent failed. Make sure the path $loggingConfigPath is writeable.", e + ) + } + + val configFilePath = targetDirectory.resolve("agent-at-port-$agentPort.properties") + val agentConfig = createAgentConfig(loggingConfigPath, targetDirectory.resolve("reports")) + try { + Files.write(configFilePath, setOf(agentConfig)) + } catch (e: IOException) { + throw MojoFailureException( + "Writing the configuration file for the TIA agent failed. Make sure the path $configFilePath is writeable.", e + ) + } + + log.info("Agent config file created at $configFilePath") + return configFilePath + } + + private fun readAgentLogbackConfig() = + TiaMojoBase::class.java.getResourceAsStream("logback-agent.xml") + + private fun createAgentConfig(loggingConfigPath: Path, agentOutputDirectory: Path): String { + var config = """ + mode=testwise + tia-mode=$tiaMode + teamscale-server-url=$teamscaleUrl + teamscale-project=$projectId + teamscale-user=$username + teamscale-access-token=$accessToken + teamscale-partition=${partition} + http-server-port=$agentPort + logging-config=$loggingConfigPath + out=${agentOutputDirectory.toAbsolutePath()} + """.trimIndent() + if (includes.isNotEmpty()) { + config += """ + + includes=${includes.joinToString(";")} + """.trimIndent() + } + if (excludes.isNotEmpty()) { + config += """ + + excludes=${excludes.joinToString(";")} + """.trimIndent() + } + if (repository.isNotBlank()) { + config += "\nteamscale-repository=$repository" + } + + config += if (!resolvedRevision.isNullOrBlank()) { + "\nteamscale-revision=$resolvedRevision" + } else { + "\nteamscale-commit=$resolvedCommit" + } + return config + } + + private fun findAgentJarFile() = + pluginArtifactMap["com.teamscale:teamscale-jacoco-agent"]?.file?.toPath() + + /** + * Sets a property in the TIA namespace. It seems that, depending on Maven version and which other plugins are used, + * different types of properties are respected both during the build and during tests (as e.g. failsafe tests are + * often run in a separate JVM spawned by Maven). So we set our properties in every possible way to make sure the + * plugin works out of the box in most situations. + */ + private fun setTiaProperty(name: String, value: String?) { + if (value == null) return + val fullyQualifiedName = "teamscale.test.impacted.$name" + log.debug("Setting property $name=$value") + session.userProperties.setProperty(fullyQualifiedName, value) + session.systemProperties.setProperty(fullyQualifiedName, value) + System.setProperty(fullyQualifiedName, value) + } + + companion object { + /** + * Name of the surefire/failsafe option to pass in + * [included engines](https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html#includeJUnit5Engines) + */ + private const val INCLUDE_JUNIT5_ENGINES_OPTION = "includeJUnit5Engines" + + /** + * Name of the surefire/failsafe option to pass in + * [excluded engines](https://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html#excludejunit5engines) + */ + private const val EXCLUDE_JUNIT5_ENGINES_OPTION = "excludeJUnit5Engines" + } +} diff --git a/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaUnitTestMojo.kt b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaUnitTestMojo.kt new file mode 100644 index 000000000..67c4f7eb8 --- /dev/null +++ b/teamscale-maven-plugin/src/main/kotlin/com/teamscale/maven/tia/TiaUnitTestMojo.kt @@ -0,0 +1,27 @@ +package com.teamscale.maven.tia + +import org.apache.maven.plugins.annotations.LifecyclePhase +import org.apache.maven.plugins.annotations.Mojo +import org.apache.maven.plugins.annotations.Parameter +import org.apache.maven.plugins.annotations.ResolutionScope + +/** + * Instruments the Surefire unit tests and uploads testwise coverage to Teamscale. + */ +@Mojo( + name = "prepare-tia-unit-test", + defaultPhase = LifecyclePhase.INITIALIZE, + requiresDependencyResolution = ResolutionScope.RUNTIME, + threadSafe = true +) +class TiaUnitTestMojo : TiaMojoBase() { + /** + * The partition to which to upload unit test coverage. + */ + @Parameter(defaultValue = "Unit Tests") + override lateinit var partition: String + + override val isIntegrationTest = false + override val testPluginArtifact = "org.apache.maven.plugins:maven-surefire-plugin" + override val testPluginPropertyPrefix = "surefire" +}