diff --git a/its/src/test/java/org/sonar/plugins/jacoco/its/JacocoTest.java b/its/src/test/java/org/sonar/plugins/jacoco/its/JacocoTest.java index a385c086..e94eab3d 100644 --- a/its/src/test/java/org/sonar/plugins/jacoco/its/JacocoTest.java +++ b/its/src/test/java/org/sonar/plugins/jacoco/its/JacocoTest.java @@ -36,7 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat; -public class JacocoTest { +class JacocoTest { private final static String PROJECT_KEY = "jacoco-test-project"; private static final String FILE_KEY = "jacoco-test-project:src/main/java/org/sonarsource/test/Calc.java"; private static final String KOTLIN_FILE_KEY = "org.sonarsource.it.projects:kotlin-jacoco-project:src/main/kotlin/CoverMe.kt"; @@ -223,6 +223,23 @@ void aggregate_and_module_based_reports_complement_each_over_to_build_total_cove .containsEntry("uncovered_conditions", 0.0) .containsEntry("coverage", 100.0); + Map measuresForLibraryClash = getCoverageMeasures("org.example:aggregate-and-module-based-mixed-coverage:library-clash/src/main/java/org/example/Library.java"); + assertThat(measuresForLibraryClash) + .containsEntry("line_coverage", 100.0) + .containsEntry("lines_to_cover", 2.0) + .containsEntry("uncovered_lines", 0.0) + .containsEntry("coverage", 100.0); + + Map measuresForLibraryNested = getCoverageMeasures("org.example:aggregate-and-module-based-mixed-coverage:nested/library/src/main/java/org/example/Library.java"); + assertThat(measuresForLibraryNested) + .containsEntry("branch_coverage", 100.0) + .containsEntry("conditions_to_cover", 2.0) + .containsEntry("coverage", 100.0) + .containsEntry("line_coverage", 100.0) + .containsEntry("lines_to_cover", 7.0) + .containsEntry("uncovered_conditions", 0.0) + .containsEntry("uncovered_lines", 0.0); + Map measuresForSquarer = getCoverageMeasures("org.example:aggregate-and-module-based-mixed-coverage:self-covered/src/main/java/org/example/Squarer.java"); assertThat(measuresForSquarer) .containsEntry("line_coverage", 100.0) diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/README.md b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/README.md index fc0f365d..1727351c 100644 --- a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/README.md +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/README.md @@ -1,11 +1,14 @@ # Aggregate Maven project -A project with 4 modules: +A project with 7 modules with 100% coverage: 1. [library](./library) - containing code but no tests -2. [library.test](./library.test) - containing test code that uses code from `library` -3. [report](./report) - generating the aggregate coverage report -4. [self-covered](./self-covered) - containing code, tests and generating its own module-based coverage report +2. [library-clash](./library-clash) - containing a class whose name clashes with the one in `library` +3. [nested](./nested) - containing one subproject +4. [library-nested](./nested/library) - a nested subproject containing a class whose name clashes with the one in `library` +5. [library-test](./library.test) - containing test code that uses code from `library` +6. [report](./report) - generating the aggregate coverage report +7[self-covered](./self-covered) - containing code, tests and generating its own module-based coverage report The report can be generated by running the following command: diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/pom.xml b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/pom.xml new file mode 100644 index 00000000..afedfbf7 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + org.example + aggregate-and-module-based-mixed-coverage + 1.0-SNAPSHOT + + + library-clash + jar + + + + org.junit.jupiter + junit-jupiter-api + 6.0.1 + test + + + \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/main/java/org/example/Library.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/main/java/org/example/Library.java new file mode 100644 index 00000000..86d954fb --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/main/java/org/example/Library.java @@ -0,0 +1,7 @@ +package org.example; + +public final class Library { + String greet(String name) { + return String.format("Hello, %s!", name); + } +} \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/test/java/org/example/LibraryTest.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/test/java/org/example/LibraryTest.java new file mode 100644 index 00000000..dd0f00e2 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/library-clash/src/test/java/org/example/LibraryTest.java @@ -0,0 +1,12 @@ +package org.example; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class LibraryTest { + @Test + void incompleteTest() { + Library library = new Library(); + Assertions.assertEquals("Hello, World!", library.greet("World")); + } +} \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/pom.xml b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/pom.xml new file mode 100644 index 00000000..fa0bd054 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + + + org.example + nested + 1.0-SNAPSHOT + + + library-nested + jar + + + + org.junit.jupiter + junit-jupiter-api + 6.0.1 + test + + + + \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/main/java/org/example/Library.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/main/java/org/example/Library.java new file mode 100644 index 00000000..c2a237b0 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/main/java/org/example/Library.java @@ -0,0 +1,27 @@ +package org.example; + +import java.util.Objects; + +/** + * This class contains code that has very little to do with the libraries + * It is also shaped in a way that applying coverage metrics from the other files should not work. + */ +public final class Library { + public final String name; + + public Library(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Library)) return false; + Library library = (Library) o; + return Objects.equals(name, library.name); + } + + @Override + public int hashCode() { + return Objects.hashCode(name); + } +} \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/test/java/org/example/LibraryTest.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/test/java/org/example/LibraryTest.java new file mode 100644 index 00000000..9b5146b4 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/library/src/test/java/org/example/LibraryTest.java @@ -0,0 +1,20 @@ +package org.example; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class LibraryTest { + @Test + void test() { + Library library = new Library("City"); + Assertions.assertEquals("City", library.name); + + Library similarLibrary = new Library("City"); + Assertions.assertEquals(library, similarLibrary); + Assertions.assertEquals(library.hashCode(), library.hashCode()); + Assertions.assertEquals(library.hashCode(), similarLibrary.hashCode()); + + Assertions.assertNotEquals(library, new Object()); + Assertions.assertNotEquals(library, new Library("Other")); + } +} diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/pom.xml b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/pom.xml new file mode 100644 index 00000000..742423a9 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + + + org.example + aggregate-and-module-based-mixed-coverage + 1.0-SNAPSHOT + + + nested + pom + + + library + + \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/main/java/org/example/Library.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/main/java/org/example/Library.java new file mode 100644 index 00000000..86d954fb --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/main/java/org/example/Library.java @@ -0,0 +1,7 @@ +package org.example; + +public final class Library { + String greet(String name) { + return String.format("Hello, %s!", name); + } +} \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/test/java/org/example/LibraryTest.java b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/test/java/org/example/LibraryTest.java new file mode 100644 index 00000000..dd0f00e2 --- /dev/null +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/nested/src/test/java/org/example/LibraryTest.java @@ -0,0 +1,12 @@ +package org.example; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class LibraryTest { + @Test + void incompleteTest() { + Library library = new Library(); + Assertions.assertEquals("Hello, World!", library.greet("World")); + } +} \ No newline at end of file diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/pom.xml b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/pom.xml index fb78b2b9..13ba7c9b 100644 --- a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/pom.xml +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/pom.xml @@ -11,6 +11,8 @@ library + library-clash + nested library-test report self-covered diff --git a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/report/pom.xml b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/report/pom.xml index 77833d03..0998fb69 100644 --- a/its/src/test/resources/aggregate-and-module-based-mixed-coverage/report/pom.xml +++ b/its/src/test/resources/aggregate-and-module-based-mixed-coverage/report/pom.xml @@ -19,6 +19,18 @@ ${project.version} compile + + org.example + library-clash + ${project.version} + compile + + + org.example + library-nested + ${project.version} + compile + org.example library-test diff --git a/src/main/java/org/sonar/plugins/jacoco/FileLocator.java b/src/main/java/org/sonar/plugins/jacoco/FileLocator.java index b9419908..6b1ca964 100644 --- a/src/main/java/org/sonar/plugins/jacoco/FileLocator.java +++ b/src/main/java/org/sonar/plugins/jacoco/FileLocator.java @@ -19,37 +19,102 @@ */ package org.sonar.plugins.jacoco; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; import javax.annotation.CheckForNull; +import javax.annotation.Nullable; import org.sonar.api.batch.fs.InputFile; public class FileLocator { private final ReversePathTree tree = new ReversePathTree(); private final KotlinFileLocator kotlinFileLocator; + private ProjectCoverageContext projectCoverageContext; public FileLocator(Iterable inputFiles, KotlinFileLocator kotlinFileLocator) { - this(StreamSupport.stream(inputFiles.spliterator(), false).collect(Collectors.toList()), kotlinFileLocator); + this(StreamSupport.stream(inputFiles.spliterator(), false).collect(Collectors.toList()), kotlinFileLocator,null); + } + + public FileLocator(Iterable inputFiles, KotlinFileLocator kotlinFileLocator, ProjectCoverageContext projectCoverageContext) { + this(StreamSupport.stream(inputFiles.spliterator(), false).collect(Collectors.toList()), kotlinFileLocator, projectCoverageContext); } public FileLocator(List inputFiles, KotlinFileLocator kotlinFileLocator) { + this(inputFiles, kotlinFileLocator, null); + } + + public FileLocator(List inputFiles, KotlinFileLocator kotlinFileLocator, @Nullable ProjectCoverageContext projectCoverageContext) { this.kotlinFileLocator = kotlinFileLocator; for (InputFile inputFile : inputFiles) { String[] path = inputFile.relativePath().split("/"); tree.index(inputFile, path); } + this.projectCoverageContext = projectCoverageContext; } @CheckForNull + /* Visible for testing */ public InputFile getInputFile(String packagePath, String fileName) { - String filePath = packagePath.isEmpty() ? fileName : (packagePath + "/" + fileName); + return getInputFile(null, packagePath, fileName); + } + + @CheckForNull + public InputFile getInputFile(@Nullable String groupName, String packagePath, String fileName) { + String filePath = packagePath.isEmpty() + ? fileName + : (packagePath + '/' + fileName); String[] path = filePath.split("/"); - InputFile fileWithSuffix = tree.getFileWithSuffix(path); + + InputFile fileWithSuffix = groupName == null + ? tree.getFileWithSuffix(path) + : getInputFileForProject(groupName, filePath); + if (fileWithSuffix == null && fileName.endsWith(".kt")) { fileWithSuffix = kotlinFileLocator.getInputFile(packagePath, fileName); } return fileWithSuffix; } + + @CheckForNull + private InputFile getInputFileForProject(String groupName, String filePath) { + // First, try to look up the file in the tree using the computed path + String[] pathSegments = filePath.split("/"); + InputFile file = tree.getFileWithSuffix(groupName, pathSegments); + if (file != null) { + return file; + } + // If the file cannot be found by looking up the tree, due for instance to ambiguities between sub-projects with similar structures, + // then we must rebuild the path by identifying the correct sub-project, and building the path from its known sources + return projectCoverageContext.getModuleContexts() + .stream() + .filter(mcc -> groupName.equals(mcc.name)) + .findFirst() + .map(mcc -> getInputFileForModule(mcc, filePath)).orElse(null); + } + + @CheckForNull + private InputFile getInputFileForModule(ModuleCoverageContext moduleCoverageContext, String filePath) { + for (Path source : moduleCoverageContext.sources) { + if (Files.isDirectory(source)) { + Path relativePath = projectCoverageContext.getProjectBaseDir().relativize(source.resolve(Paths.get(filePath))); + String[] segments = relativePath.toString().split("/"); + InputFile file = tree.getFileWithSuffix(segments); + if (file != null) { + return file; + } + } else if (source.endsWith(filePath)) { + Path relativePah = projectCoverageContext.getProjectBaseDir().relativize(source); + String[] segments = relativePah.toString().split("/"); + InputFile file = tree.getFileWithSuffix(segments); + if (file != null) { + return file; + } + } + } + return null; + } } diff --git a/src/main/java/org/sonar/plugins/jacoco/JacocoAggregateSensor.java b/src/main/java/org/sonar/plugins/jacoco/JacocoAggregateSensor.java index 1a767cc0..0921ac82 100644 --- a/src/main/java/org/sonar/plugins/jacoco/JacocoAggregateSensor.java +++ b/src/main/java/org/sonar/plugins/jacoco/JacocoAggregateSensor.java @@ -21,6 +21,7 @@ import java.io.FileNotFoundException; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.sonar.api.batch.fs.InputFile; @@ -32,6 +33,11 @@ public class JacocoAggregateSensor implements ProjectSensor { private static final Logger LOG = Loggers.get(JacocoAggregateSensor.class); + private final ProjectCoverageContext projectCoverageContext; + + public JacocoAggregateSensor(ProjectCoverageContext projectCoverageContext) { + this.projectCoverageContext = projectCoverageContext; + } @Override public void describe(SensorDescriptor descriptor) { @@ -40,6 +46,7 @@ public void describe(SensorDescriptor descriptor) { @Override public void execute(SensorContext context) { + this.projectCoverageContext.setProjectBaseDir(Paths.get(context.config().get("sonar.projectBaseDir").get())); Path reportPath = null; try { reportPath = new ReportPathsProvider(context).getAggregateReportPath(); @@ -53,7 +60,7 @@ public void execute(SensorContext context) { } Iterable inputFiles = context.fileSystem().inputFiles(context.fileSystem().predicates().all()); Stream kotlinInputFileStream = StreamSupport.stream(inputFiles.spliterator(), false).filter(f -> "kotlin".equals(f.language())); - FileLocator locator = new FileLocator(inputFiles, new KotlinFileLocator(kotlinInputFileStream)); + FileLocator locator = new FileLocator(inputFiles, new KotlinFileLocator(kotlinInputFileStream), projectCoverageContext); ReportImporter importer = new ReportImporter(context); LOG.info("Importing aggregate report {}.", reportPath); diff --git a/src/main/java/org/sonar/plugins/jacoco/JacocoPlugin.java b/src/main/java/org/sonar/plugins/jacoco/JacocoPlugin.java index 5ae1fec4..838d68a1 100644 --- a/src/main/java/org/sonar/plugins/jacoco/JacocoPlugin.java +++ b/src/main/java/org/sonar/plugins/jacoco/JacocoPlugin.java @@ -27,6 +27,7 @@ public class JacocoPlugin implements Plugin { @Override public void define(Context context) { + context.addExtension(ProjectCoverageContext.class); context.addExtension(JacocoSensor.class); context.addExtension(PropertyDefinition.builder(ReportPathsProvider.REPORT_PATHS_PROPERTY_KEY) .onQualifiers(Qualifiers.PROJECT) diff --git a/src/main/java/org/sonar/plugins/jacoco/JacocoSensor.java b/src/main/java/org/sonar/plugins/jacoco/JacocoSensor.java index cdd0b15b..741a7b41 100644 --- a/src/main/java/org/sonar/plugins/jacoco/JacocoSensor.java +++ b/src/main/java/org/sonar/plugins/jacoco/JacocoSensor.java @@ -33,6 +33,12 @@ public class JacocoSensor implements Sensor { private static final Logger LOG = Loggers.get(JacocoSensor.class); + private final ProjectCoverageContext projectCoverageContext; + + public JacocoSensor(ProjectCoverageContext projectCoverageContext) { + this.projectCoverageContext = projectCoverageContext; + } + @Override public void describe(SensorDescriptor descriptor) { descriptor.name("JaCoCo XML Report Importer"); @@ -40,6 +46,7 @@ public void describe(SensorDescriptor descriptor) { @Override public void execute(SensorContext context) { + recordModuleCoverageContext(context); Collection reportPaths = new ReportPathsProvider(context).getPaths(); if (reportPaths.isEmpty()) { LOG.info("No report imported, no coverage information will be imported by JaCoCo XML Report Importer"); @@ -65,4 +72,10 @@ void importReports(Collection reportPaths, FileLocator locator, ReportImpo } } } + + private void recordModuleCoverageContext(SensorContext sensorContext) { + var moduleCoverageContext = ModuleCoverageContext.from(sensorContext); + this.projectCoverageContext.add(moduleCoverageContext); + LOG.debug(String.format("Recorded module coverage context for aggregation: %s", moduleCoverageContext)); + } } diff --git a/src/main/java/org/sonar/plugins/jacoco/ModuleCoverageContext.java b/src/main/java/org/sonar/plugins/jacoco/ModuleCoverageContext.java new file mode 100644 index 00000000..6637da36 --- /dev/null +++ b/src/main/java/org/sonar/plugins/jacoco/ModuleCoverageContext.java @@ -0,0 +1,92 @@ +/* + * SonarQube JaCoCo Plugin + * Copyright (C) 2018-2026 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.jacoco; + +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.api.config.Configuration; + +/** + * The context necessary to enable the aggregate sensor connect a SourceFile to a module. + */ +public class ModuleCoverageContext { + public final String name; + public final Path baseDir; + public final List sources; + + ModuleCoverageContext(String name, Path baseDir, List sourceDirectories) { + this.name = name; + this.baseDir = baseDir; + this.sources = sourceDirectories; + } + + static ModuleCoverageContext from(SensorContext sensorContext) { + Configuration config = sensorContext.config(); + String moduleKey = extractModuleKey(config); + Path baseDir = Path.of(config.get("sonar.projectBaseDir").get()); + List sourceDirectories = Arrays.asList(config.getStringArray("sonar.sources")) + .stream() + .map(Path::of) + .map(dir -> dir.isAbsolute() ? dir : baseDir.resolve(dir)) + .collect(Collectors.toList()); + return new ModuleCoverageContext(moduleKey, baseDir, sourceDirectories); + } + + static String extractModuleKey(Configuration configuration) { + Optional module = configuration.get("sonar.moduleKey"); + String moduleKey = module.isPresent() + ? module.get() + : configuration.get("sonar.projectKey").get(); + // If the module key contains a colon, we are looking at a subproject and should use the text that follows the last colon in the module key + int indexOfLastColon = moduleKey.lastIndexOf(':'); + if (indexOfLastColon == -1 || moduleKey.length() <= (indexOfLastColon + 1)) { + return moduleKey; + } + return moduleKey.substring(indexOfLastColon + 1); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof ModuleCoverageContext)) return false; + ModuleCoverageContext that = (ModuleCoverageContext) o; + return Objects.equals(name, that.name) && + Objects.equals(baseDir, that.baseDir) && + Objects.equals(sources, that.sources); + } + + @Override + public int hashCode() { + return Objects.hash(name, baseDir, sources); + } + + @Override + public String toString() { + return "ModuleCoverageContext{" + + "name='" + name + '\'' + + ", baseDir=" + baseDir + + ", sourceDirectories=" + sources + + '}'; + } +} diff --git a/src/main/java/org/sonar/plugins/jacoco/ProjectCoverageContext.java b/src/main/java/org/sonar/plugins/jacoco/ProjectCoverageContext.java new file mode 100644 index 00000000..357630aa --- /dev/null +++ b/src/main/java/org/sonar/plugins/jacoco/ProjectCoverageContext.java @@ -0,0 +1,49 @@ +/* + * SonarQube JaCoCo Plugin + * Copyright (C) 2018-2026 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.jacoco; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.sonar.api.scanner.ScannerSide; + +@ScannerSide +public class ProjectCoverageContext { + private Path projectBaseDir; + + private List moduleContexts = new ArrayList<>(); + + public List getModuleContexts() { + return moduleContexts; + } + + public void add(ModuleCoverageContext moduleContext) { + this.moduleContexts.add(moduleContext); + } + + + public Path getProjectBaseDir() { + return projectBaseDir; + } + + public void setProjectBaseDir(Path projectBaseDir) { + this.projectBaseDir = projectBaseDir; + } +} diff --git a/src/main/java/org/sonar/plugins/jacoco/ReversePathTree.java b/src/main/java/org/sonar/plugins/jacoco/ReversePathTree.java index 7ee5720d..d752d354 100644 --- a/src/main/java/org/sonar/plugins/jacoco/ReversePathTree.java +++ b/src/main/java/org/sonar/plugins/jacoco/ReversePathTree.java @@ -46,6 +46,30 @@ public InputFile getFileWithSuffix(String[] path) { return getFirstLeaf(currentNode); } + public InputFile getFileWithSuffix(String module, String[] path) { + Node currentNode = root; + for (int i = path.length - 1; i >= 0; i--) { + currentNode = currentNode.children.get(path[i]); + if (currentNode == null) { + return null; + } + } + + while (!currentNode.children.isEmpty()) { + if (currentNode.children.size() == 1) { + currentNode = currentNode.children.values().iterator().next(); + } else { + for (String candidate : currentNode.children.keySet()) { + if (module.equals(candidate)) { + return currentNode.children.get(candidate).file; + } + } + return null; + } + } + return null; + } + private static InputFile getFirstLeaf(Node node) { while (!node.children.isEmpty()) { node = node.children.values().iterator().next(); diff --git a/src/main/java/org/sonar/plugins/jacoco/SensorUtils.java b/src/main/java/org/sonar/plugins/jacoco/SensorUtils.java index 32aceafe..d3723b0a 100644 --- a/src/main/java/org/sonar/plugins/jacoco/SensorUtils.java +++ b/src/main/java/org/sonar/plugins/jacoco/SensorUtils.java @@ -32,7 +32,7 @@ static void importReport(XmlReportParser reportParser, FileLocator locator, Repo List sourceFiles = reportParser.parse(); for (XmlReportParser.SourceFile sourceFile : sourceFiles) { - InputFile inputFile = locator.getInputFile(sourceFile.packageName(), sourceFile.name()); + InputFile inputFile = locator.getInputFile(sourceFile.groupName(), sourceFile.packageName(), sourceFile.name()); if (inputFile == null) { continue; } diff --git a/src/main/java/org/sonar/plugins/jacoco/XmlReportParser.java b/src/main/java/org/sonar/plugins/jacoco/XmlReportParser.java index 2518e59e..cbae7fff 100644 --- a/src/main/java/org/sonar/plugins/jacoco/XmlReportParser.java +++ b/src/main/java/org/sonar/plugins/jacoco/XmlReportParser.java @@ -28,6 +28,8 @@ import java.util.List; import java.util.Objects; import java.util.function.Supplier; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamConstants; import javax.xml.stream.XMLStreamException; @@ -55,6 +57,7 @@ public List parse() { List sourceFiles = new ArrayList<>(); + String groupName = null; String packageName = null; String sourceFileName = null; @@ -70,11 +73,15 @@ public List parse() { packageName = null; } else if (element.equals("sourcefile")) { sourceFileName = null; + } else if (element.equals("group")) { + groupName = null; } } else if (event == XMLStreamConstants.START_ELEMENT) { String element = parser.getLocalName(); - if (element.equals("package")) { + if (element.equals("group")) { + groupName = getStringAttr(parser, "name", () -> "for a 'group' at line" + parser.getLocation().getLineNumber() + COLUMN + parser.getLocation().getColumnNumber()); + } else if (element.equals("package")) { packageName = getStringAttr(parser, "name", () -> "for a 'package' at line " + parser.getLocation().getLineNumber() + COLUMN + parser.getLocation().getColumnNumber()); } else if (element.equals("sourcefile")) { if (packageName == null) { @@ -83,7 +90,7 @@ public List parse() { } sourceFileName = getStringAttr(parser, "name", () -> "for a sourcefile at line " + parser.getLocation().getLineNumber() + COLUMN + parser.getLocation().getColumnNumber()); - sourceFiles.add(new SourceFile(packageName, sourceFileName)); + sourceFiles.add(new SourceFile(packageName, sourceFileName, groupName)); } else if (element.equals("line")) { if (sourceFileName == null) { throw new IllegalStateException("Invalid report: expected to find 'line' within a 'sourcefile' at line " @@ -150,11 +157,17 @@ private static int getIntAttr(XMLStreamReader parser, String name, Supplier lines = new ArrayList<>(); SourceFile(String packageName, String name) { + this(packageName, name, null); + } + + SourceFile(String packageName, String name, @Nullable String groupName) { this.name = name; this.packageName = packageName; + this.groupName = groupName; } public String name() { @@ -165,6 +178,14 @@ public String packageName() { return packageName; } + @CheckForNull + /** + * Name of the group, aka (sub-)project, which is optional information grouping source files in aggregate reports. + */ + public String groupName() { + return groupName; + } + public List lines() { return lines; } diff --git a/src/test/java/org/sonar/plugins/jacoco/FileLocatorTest.java b/src/test/java/org/sonar/plugins/jacoco/FileLocatorTest.java index 99f4d59f..531afa73 100644 --- a/src/test/java/org/sonar/plugins/jacoco/FileLocatorTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/FileLocatorTest.java @@ -19,14 +19,18 @@ */ package org.sonar.plugins.jacoco; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.fs.internal.TestInputFileBuilder; import static org.assertj.core.api.Assertions.assertThat; - import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -93,4 +97,101 @@ void should_not_fallback_on_Kotlin_file_locator_if_file_is_not_Kotlin() { verify(kotlinFileLocatorMock, never()).getInputFile(any(), any()); } + @Test + void should_be_able_to_look_up_ambiguous_names(@TempDir Path temp) throws IOException { + /* + /tmp/junit153873785933058202/my-project + ├── app + │ ├── pom.xml + │ ├── src + │ │ └── main + │ │ └── java + │ │ └── File.java + │ └── utils + │ ├── pom.xml + │ └── src + │ └── main + │ └── java + │ └── File.java + ├── pom.xml + └── utils + ├── pom.xml + └── src + └── main + └── java + └── File.java + */ + + // Top level project + Path myProjectBaseDir = Files.createDirectories(temp.resolve("my-project")); + Path myProjectPomXml = Files.createFile(myProjectBaseDir.resolve("pom.xml")); + // App module + Path appModuleBaseDir = Files.createDirectory(myProjectBaseDir.resolve("app")); + Path appModuleJavaSources = Files.createDirectories(appModuleBaseDir.resolve("src").resolve("main").resolve("java")); + Path appModuleFileJava = Files.createFile(appModuleJavaSources.resolve("File.java")); + Path appModulePomXml = Files.createFile(appModuleBaseDir.resolve("pom.xml")); + // Utils module + Path utilsModuleBaseDir = Files.createDirectory(myProjectBaseDir.resolve("utils")); + Path utilsModuleJavaSources = Files.createDirectories(utilsModuleBaseDir.resolve("src").resolve("main").resolve("java")); + Path utilsModuleFileJava = Files.createFile(utilsModuleJavaSources.resolve("File.java")); + Path utilsModulePomXml = Files.createFile(utilsModuleBaseDir.resolve("pom.xml")); + // Utils module nested into App module + Path nestedUtilsModuleBaseDir = Files.createDirectory(appModuleBaseDir.resolve("utils")); + Path nestedUtilsModuleJavaSources = Files.createDirectories(nestedUtilsModuleBaseDir.resolve("src").resolve("main").resolve("java")); + Path nestedUtilsModuleFileJava = Files.createFile(nestedUtilsModuleJavaSources.resolve("File.java")); + Path nestedUtilsModulePomXml = Files.createFile(nestedUtilsModuleBaseDir.resolve("pom.xml")); + + // Prepare all the input files to index + InputFile appFile = new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), appModuleFileJava.toFile()).build(); + InputFile utilsFile = new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), utilsModuleFileJava.toFile()).build(); + InputFile nestedUtilsFile = new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), nestedUtilsModuleFileJava.toFile()).build(); + InputFile nestUtilsPomXmlFile = new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), nestedUtilsModulePomXml.toFile()).build(); + List filesToIndex = List.of( + new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), myProjectPomXml.toFile()).build(), + appFile, + new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), appModulePomXml.toFile()).build(), + utilsFile, + new TestInputFileBuilder("my-project", myProjectBaseDir.toFile(), utilsModulePomXml.toFile()).build(), + nestedUtilsFile, + nestUtilsPomXmlFile + ); + + ProjectCoverageContext pcc = new ProjectCoverageContext(); + pcc.setProjectBaseDir(myProjectBaseDir); + pcc.add( + new ModuleCoverageContext( + "app", + appModuleBaseDir, + List.of(appModulePomXml, appModuleJavaSources) + ) + ); + + pcc.add( + new ModuleCoverageContext( + "utils", + utilsModuleBaseDir, + List.of(utilsModulePomXml, utilsModuleJavaSources) + ) + ); + + pcc.add( + new ModuleCoverageContext( + "app-utils", + utilsModuleBaseDir, + List.of(nestedUtilsModulePomXml, nestedUtilsModuleJavaSources) + ) + ); + + FileLocator locator = new FileLocator(filesToIndex, null, pcc); + + // Test existing files + assertThat(locator.getInputFile("app", "", "File.java")).isEqualTo(appFile); + assertThat(locator.getInputFile("utils", "", "File.java")).isEqualTo(utilsFile); + assertThat(locator.getInputFile("app-utils", "", "File.java")).isEqualTo(nestedUtilsFile); + assertThat(locator.getInputFile("app-utils", "", "pom.xml")).isEqualTo(nestUtilsPomXmlFile); + + // Test non-existing files + assertThat(locator.getInputFile("app", "org/example", "Main.java")).isNull(); + } + } diff --git a/src/test/java/org/sonar/plugins/jacoco/JacocoAggregateSensorTest.java b/src/test/java/org/sonar/plugins/jacoco/JacocoAggregateSensorTest.java index 894b9a91..23a353cf 100644 --- a/src/test/java/org/sonar/plugins/jacoco/JacocoAggregateSensorTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/JacocoAggregateSensorTest.java @@ -28,7 +28,6 @@ import org.junit.jupiter.api.io.TempDir; import org.sonar.api.batch.sensor.SensorDescriptor; import org.sonar.api.batch.sensor.internal.SensorContextTester; -import org.sonar.api.config.internal.MapSettings; import org.sonar.api.utils.log.LogTesterJUnit5; import org.sonar.api.utils.log.LoggerLevel; @@ -49,19 +48,21 @@ class JacocoAggregateSensorTest { @BeforeEach void setup() { context = SensorContextTester.create(basedir); + context.settings().clear(); + context.settings().setProperty("sonar.projectBaseDir", basedir.toString()); } @Test void description_name_is_as_expected() { SensorDescriptor descriptor = mock(SensorDescriptor.class); - var sensor = new JacocoAggregateSensor(); + var sensor = new JacocoAggregateSensor(new ProjectCoverageContext()); sensor.describe(descriptor); verify(descriptor).name("JaCoCo Aggregate XML Report Importer"); } @Test void log_missing_report_and_return_early_when_missing_analysis_parameter() { - var sensor = new JacocoAggregateSensor(); + var sensor = new JacocoAggregateSensor(new ProjectCoverageContext()); sensor.execute(context); assertThat(logTester.logs(LoggerLevel.DEBUG)).containsOnly(NO_REPORT_TO_IMPORT_LOG_MESSAGE); @@ -69,11 +70,10 @@ void log_missing_report_and_return_early_when_missing_analysis_parameter() { @Test void log_missing_report_and_return_early_when_analysis_parameter_points_to_report_that_does_not_exist() { - MapSettings settings = new MapSettings(); - settings.setProperty(ReportPathsProvider.AGGREGATE_REPORT_PATH_PROPERTY_KEY, "non-existing-report.xml"); - context.setSettings(settings); + context.settings() + .setProperty(ReportPathsProvider.AGGREGATE_REPORT_PATH_PROPERTY_KEY, "non-existing-report.xml"); - var sensor = new JacocoAggregateSensor(); + var sensor = new JacocoAggregateSensor(new ProjectCoverageContext()); sensor.execute(context); assertThat(logTester.logs(LoggerLevel.ERROR)). @@ -87,11 +87,10 @@ void executes_as_expected() throws IOException { Path reportPath = basedir.resolve("my-aggregate-report.xml"); Files.copy(Path.of("src", "test", "resources", "jacoco.xml"), reportPath); - MapSettings settings = new MapSettings(); - settings.setProperty(ReportPathsProvider.AGGREGATE_REPORT_PATH_PROPERTY_KEY, reportPath.toAbsolutePath().toString()); - context.setSettings(settings); + context.settings() + .setProperty(ReportPathsProvider.AGGREGATE_REPORT_PATH_PROPERTY_KEY, reportPath.toAbsolutePath().toString()); - var sensor = new JacocoAggregateSensor(); + var sensor = new JacocoAggregateSensor(new ProjectCoverageContext()); sensor.execute(context); assertThat(logTester.logs(LoggerLevel.DEBUG)).doesNotContain(NO_REPORT_TO_IMPORT_LOG_MESSAGE); assertThat(logTester.logs(LoggerLevel.INFO)).containsOnly( diff --git a/src/test/java/org/sonar/plugins/jacoco/JacocoPluginTest.java b/src/test/java/org/sonar/plugins/jacoco/JacocoPluginTest.java index 6c967220..d8a99e9e 100644 --- a/src/test/java/org/sonar/plugins/jacoco/JacocoPluginTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/JacocoPluginTest.java @@ -40,19 +40,21 @@ void should_add_sensors_and_property_definitions() { plugin.define(ctx); ArgumentCaptor arg = ArgumentCaptor.forClass(Object.class); - verify(ctx, times(4)).addExtension(arg.capture()); + verify(ctx, times(5)).addExtension(arg.capture()); verifyNoMoreInteractions(ctx); - assertThat(arg.getAllValues().get(0)).isEqualTo(JacocoSensor.class); - assertThat(arg.getAllValues().get(1)).isInstanceOf(PropertyDefinition.class); - PropertyDefinition multiValueReportPaths = (PropertyDefinition) arg.getAllValues().get(1); + assertThat(arg.getAllValues().get(0)).isEqualTo(ProjectCoverageContext.class); + + assertThat(arg.getAllValues().get(1)).isEqualTo(JacocoSensor.class); + assertThat(arg.getAllValues().get(2)).isInstanceOf(PropertyDefinition.class); + PropertyDefinition multiValueReportPaths = (PropertyDefinition) arg.getAllValues().get(2); assertThat(multiValueReportPaths.key()).isEqualTo("sonar.coverage.jacoco.xmlReportPaths"); assertThat(multiValueReportPaths.multiValues()).isTrue(); assertThat(multiValueReportPaths.category()).isEqualTo("JaCoCo"); assertThat(multiValueReportPaths.qualifiers()).containsOnly(Qualifiers.PROJECT); - assertThat(arg.getAllValues().get(2)).isEqualTo(JacocoAggregateSensor.class); - PropertyDefinition aggregateReportPath = (PropertyDefinition) arg.getAllValues().get(3); + assertThat(arg.getAllValues().get(3)).isEqualTo(JacocoAggregateSensor.class); + PropertyDefinition aggregateReportPath = (PropertyDefinition) arg.getAllValues().get(4); assertThat(aggregateReportPath.key()).isEqualTo("sonar.coverage.jacoco.aggregateXmlReportPath"); assertThat(aggregateReportPath.type()).isEqualTo(PropertyType.STRING); assertThat(aggregateReportPath.multiValues()).isFalse(); diff --git a/src/test/java/org/sonar/plugins/jacoco/JacocoSensorTest.java b/src/test/java/org/sonar/plugins/jacoco/JacocoSensorTest.java index 06e6485b..88671121 100644 --- a/src/test/java/org/sonar/plugins/jacoco/JacocoSensorTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/JacocoSensorTest.java @@ -20,6 +20,13 @@ package org.sonar.plugins.jacoco; import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; import org.junit.Rule; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -33,14 +40,6 @@ import org.sonar.api.utils.log.LogTesterJUnit5; import org.sonar.api.utils.log.LoggerLevel; -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.Collections; - import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -60,7 +59,7 @@ class JacocoSensorTest { @RegisterExtension public LogTesterJUnit5 logTester = new LogTesterJUnit5(); - private JacocoSensor sensor = new JacocoSensor(); + private JacocoSensor sensor = new JacocoSensor(new ProjectCoverageContext()); @Test void describe_sensor() { @@ -73,13 +72,21 @@ void describe_sensor() { void do_not_index_files_when_no_report_was_found() throws IOException { File emptyFolderWithoutReport = temp.newFolder(); SensorContextTester spiedContext = spy(SensorContextTester.create(emptyFolderWithoutReport)); + MapSettings settings = new MapSettings() + .setProperty("sonar.moduleKey", "module") + .setProperty("sonar.projectBaseDir", temp.getRoot().getAbsolutePath()); + spiedContext.setSettings(settings); DefaultFileSystem spiedFileSystem = spy(spiedContext.fileSystem()); when(spiedContext.fileSystem()).thenReturn(spiedFileSystem); sensor.execute(spiedContext); // indexing all files in the filesystem is time consuming and should not be done if there no jacoco reports to import // one way to assert this is to ensure there's no calls on fileSystem.inputFiles(...) verify(spiedFileSystem, never()).inputFiles(any()); - assertThat(logTester.logs()).containsExactlyInAnyOrder( + var infoLevelLogs = logTester.getLogs(LoggerLevel.INFO) + .stream() + .map(laa -> laa.getFormattedMsg()) + .toList(); + assertThat(infoLevelLogs).containsExactlyInAnyOrder( "'sonar.coverage.jacoco.xmlReportPaths' is not defined. Using default locations: target/site/jacoco/jacoco.xml,target/site/jacoco-it/jacoco.xml,build/reports/jacoco/test/jacocoTestReport.xml", "No report imported, no coverage information will be imported by JaCoCo XML Report Importer"); } @@ -108,7 +115,7 @@ void parse_failure_do_not_fail_analysis() { Path invalidFile = baseDir.resolve("invalid_ci_in_line.xml"); Path validFile = baseDir.resolve("jacoco.xml"); - when(locator.getInputFile("org/sonarlint/cli", "Stats.java")).thenReturn(inputFile); + when(locator.getInputFile(null, "org/sonarlint/cli", "Stats.java")).thenReturn(inputFile); sensor.importReports(Arrays.asList(invalidFile, validFile), locator, importer); @@ -127,6 +134,8 @@ void parse_failure_do_not_fail_analysis() { void test_load_real_report() throws URISyntaxException, IOException { MapSettings settings = new MapSettings(); settings.setProperty(ReportPathsProvider.REPORT_PATHS_PROPERTY_KEY, "jacoco.xml"); + settings.setProperty("sonar.moduleKey", "module"); + settings.setProperty("sonar.projectBaseDir", temp.getRoot().getAbsolutePath()); SensorContextTester tester = SensorContextTester.create(temp.getRoot()); tester.setSettings(settings); InputFile inputFile = TestInputFileBuilder @@ -149,6 +158,8 @@ void test_load_real_report() throws URISyntaxException, IOException { void import_failure_do_not_fail_analysis() throws URISyntaxException, IOException { MapSettings settings = new MapSettings(); settings.setProperty(ReportPathsProvider.REPORT_PATHS_PROPERTY_KEY, "invalid_line_number.xml"); + settings.setProperty("sonar.moduleKey", "module"); + settings.setProperty("sonar.projectBaseDir", temp.getRoot().getAbsolutePath()); SensorContextTester tester = SensorContextTester.create(temp.getRoot()); tester.setSettings(settings); InputFile inputFile = TestInputFileBuilder diff --git a/src/test/java/org/sonar/plugins/jacoco/ModuleCoverageContextTest.java b/src/test/java/org/sonar/plugins/jacoco/ModuleCoverageContextTest.java new file mode 100644 index 00000000..c3730c00 --- /dev/null +++ b/src/test/java/org/sonar/plugins/jacoco/ModuleCoverageContextTest.java @@ -0,0 +1,97 @@ +/* + * SonarQube JaCoCo Plugin + * Copyright (C) 2018-2026 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.jacoco; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.api.config.internal.MapSettings; + +import static org.assertj.core.api.Assertions.assertThat; + +class ModuleCoverageContextTest { + @TempDir + Path temp; + + @Test + void uses_the_project_key_as_the_name_when_module_key_is_not_defined() { + SensorContextTester context = SensorContextTester.create(temp); + MapSettings settings = new MapSettings(); + settings.setProperty("sonar.projectKey", "single-module-project"); + Path projectBaseDir = temp.toAbsolutePath(); + settings.setProperty("sonar.projectBaseDir", projectBaseDir.toString()); + settings.setProperty("sonar.sources", "src/main/kotlin"); + context.setSettings(settings); + + ModuleCoverageContext expected = new ModuleCoverageContext( + "single-module-project", + projectBaseDir, + List.of(projectBaseDir.resolve("src").resolve("main").resolve("kotlin")) + ); + + assertThat(ModuleCoverageContext.from(context)).isEqualTo(expected); + } + + @Test + void extracts_information_for_multi_module_project() throws IOException { + Path projectBaseDir = temp.toAbsolutePath(); + Path moduleBaseDir = temp.resolve("submodule"); + Files.createDirectories(moduleBaseDir); + + + // Because the module prefix is systematically popped when analyzing a module, we can use the same keys for each submodule. + MapSettings settings = new MapSettings() + .setProperty("sonar.moduleKey", "org.example:submodule") + .setProperty("sonar.projectBaseDir", projectBaseDir.toString()) + .setProperty("sonar.sources", "src/main/kotlin"); + + SensorContextTester context = SensorContextTester.create(projectBaseDir); + context.setSettings(settings); + + ModuleCoverageContext expected = new ModuleCoverageContext( + "submodule", + projectBaseDir, + List.of(projectBaseDir.resolve("src").resolve("main").resolve("kotlin")) + ); + + assertThat(ModuleCoverageContext.from(context)).isEqualTo(expected); + } + + @Test + void equality() { + var mcc = new ModuleCoverageContext("name", Path.of("name"), List.of(Path.of("src", "main", "java"))); + assertThat(mcc) + .isEqualTo(mcc) + .hasSameHashCodeAs(mcc); + + var otherName = new ModuleCoverageContext("other", Path.of("name"), List.of(Path.of("src", "main", "java"))); + var otherBaseDir = new ModuleCoverageContext("name", Path.of("other"), List.of(Path.of("src", "main", "java"))); + var otherSources = new ModuleCoverageContext("name", Path.of("name"), List.of(Path.of("src", "main", "kotlin"))); + assertThat(mcc) + .isNotEqualTo(otherName) + .isNotEqualTo(otherBaseDir) + .isNotEqualTo(otherSources) + .isNotEqualTo(new Object()); + } +} diff --git a/src/test/java/org/sonar/plugins/jacoco/ProjectCoverageContextTest.java b/src/test/java/org/sonar/plugins/jacoco/ProjectCoverageContextTest.java new file mode 100644 index 00000000..eeed520b --- /dev/null +++ b/src/test/java/org/sonar/plugins/jacoco/ProjectCoverageContextTest.java @@ -0,0 +1,49 @@ +/* + * SonarQube JaCoCo Plugin + * Copyright (C) 2018-2026 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.jacoco; + +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProjectCoverageContextTest { + + @Test + void is_empty_when_initialized() { + assertThat(new ProjectCoverageContext().getModuleContexts()).isEmpty(); + } + + @Test + void added_module_coverage_contexts_can_be_retrieved(@TempDir Path temp) { + ModuleCoverageContext moduleCoverageContext = new ModuleCoverageContext( + "top-level", + temp, + List.of(temp.resolve("src").resolve("main").resolve("java")) + ); + + ProjectCoverageContext projectCoverageContext = new ProjectCoverageContext(); + projectCoverageContext.add(moduleCoverageContext); + + assertThat(projectCoverageContext.getModuleContexts()).containsOnly(moduleCoverageContext); + } +} \ No newline at end of file diff --git a/src/test/java/org/sonar/plugins/jacoco/ReversePathTreeTest.java b/src/test/java/org/sonar/plugins/jacoco/ReversePathTreeTest.java new file mode 100644 index 00000000..b5e1f266 --- /dev/null +++ b/src/test/java/org/sonar/plugins/jacoco/ReversePathTreeTest.java @@ -0,0 +1,79 @@ +/* + * SonarQube JaCoCo Plugin + * Copyright (C) 2018-2026 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.plugins.jacoco; + +import org.junit.jupiter.api.Test; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.batch.fs.internal.TestInputFileBuilder; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +class ReversePathTreeTest { + @Test + void module_aware_resolution_of_input_file_with_name_clashes_across_modules_works_as_expected() { + String module = "module"; + InputFile target = TestInputFileBuilder.create("", "module/src/main/java/org/example/App.java").build(); + String[] path = target.relativePath().split("/"); + + InputFile clashingFile = TestInputFileBuilder.create("", "module-clash/src/main/java/org/example/App.java").build(); + String[] clashingPath = clashingFile.relativePath().split("/"); + + var reverseParseTree = new ReversePathTree(); + reverseParseTree.index(clashingFile, clashingPath); + reverseParseTree.index(target, path); + + String[] pathWithoutSourceDirectory = new String[]{"org", "example", "App.java"}; + assertThat(reverseParseTree.getFileWithSuffix(module, pathWithoutSourceDirectory)).isEqualTo(target); + } + + @Test + void module_aware_resolution_of_input_file_with_an_empty_index_returns_null() { + var reverseParseTree = new ReversePathTree(); + String[] pathWithoutSourceDirectory = new String[]{"src","main", "java","org", "example", "App.java"}; + assertThat(reverseParseTree.getFileWithSuffix("my-module", pathWithoutSourceDirectory)).isNull(); + } + + @Test + void module_aware_resolution_of_input_file_with_missing_file_returns_null() { + var reverseParseTree = new ReversePathTree(); + InputFile differentFile = TestInputFileBuilder.create("", "module-clash/src/main/java/org/example/App.java").build(); + String[] differentFilePath = differentFile.relativePath().split("/"); + reverseParseTree.index(differentFile, differentFilePath); + + String[] pathWithoutSourceDirectory = new String[]{"src","main", "java","org", "example", "App.java"}; + assertThat(reverseParseTree.getFileWithSuffix("my-module", pathWithoutSourceDirectory)).isNull(); + } + + @Test + void module_aware_resolution_of_input_file_with_missing_file_despite_may_similar_files_returns_null() { + var reverseParseTree = new ReversePathTree(); + + InputFile differentFile = TestInputFileBuilder.create("", "module-clash/src/main/java/org/example/App.java").build(); + String[] differentFilePath = differentFile.relativePath().split("/"); + reverseParseTree.index(differentFile, differentFilePath); + + InputFile yetAnotherDifferentFile = TestInputFileBuilder.create("", "module-clash-again/src/main/java/org/example/App.java").build(); + String[] yeAnotherDifferentFilePath = yetAnotherDifferentFile.relativePath().split("/"); + reverseParseTree.index(yetAnotherDifferentFile, yeAnotherDifferentFilePath); + + String[] pathWithoutSourceDirectory = new String[]{"src","main", "java","org", "example", "App.java"}; + assertThat(reverseParseTree.getFileWithSuffix("my-module", pathWithoutSourceDirectory)).isNull(); + } +} \ No newline at end of file diff --git a/src/test/java/org/sonar/plugins/jacoco/SensorUtilsTest.java b/src/test/java/org/sonar/plugins/jacoco/SensorUtilsTest.java index d08ea166..20a05481 100644 --- a/src/test/java/org/sonar/plugins/jacoco/SensorUtilsTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/SensorUtilsTest.java @@ -41,7 +41,7 @@ void import_coverage() { sourceFile.lines().add(new XmlReportParser.Line(1, 0, 1, 0, 0)); when(parser.parse()).thenReturn(Collections.singletonList(sourceFile)); - when(locator.getInputFile("package", "File.java")).thenReturn(inputFile); + when(locator.getInputFile(null,"package", "File.java")).thenReturn(inputFile); SensorUtils.importReport(parser, locator, importer, null); diff --git a/src/test/java/org/sonar/plugins/jacoco/XmlReportParserTest.java b/src/test/java/org/sonar/plugins/jacoco/XmlReportParserTest.java index 7a5e59da..e2bd995f 100644 --- a/src/test/java/org/sonar/plugins/jacoco/XmlReportParserTest.java +++ b/src/test/java/org/sonar/plugins/jacoco/XmlReportParserTest.java @@ -185,4 +185,25 @@ void line_equality_checks_work_as_expected() { .isNotEqualTo(new XmlReportParser.Line(1, 2, 3, 42, 5)) .isNotEqualTo(new XmlReportParser.Line(1, 2, 3, 4, 42)); } + + @Test + void should_import_aggregate_report() throws URISyntaxException { + Path sample = load("jacoco-aggregate.xml"); + XmlReportParser parser = new XmlReportParser(sample); + + List sourceFiles = parser.parse(); + + assertThat(sourceFiles).hasSize(1); + var singleFile = sourceFiles.get(0); + assertThat(singleFile.packageName()).isEqualTo("org/example"); + assertThat(singleFile.name()).isEqualTo("Library.java"); + assertThat(singleFile.lines()).containsExactly( + new XmlReportParser.Line(3, 3, 0, 0, 0), + new XmlReportParser.Line(5, 0, 2, 1, 1), + new XmlReportParser.Line(6, 2, 0, 0, 0), + new XmlReportParser.Line(8, 0, 5, 0, 0) + ); + + assertThat(singleFile.groupName()).isEqualTo("library"); + } } diff --git a/src/test/resources/jacoco-aggregate.xml b/src/test/resources/jacoco-aggregate.xml new file mode 100644 index 00000000..d31ae50a --- /dev/null +++ b/src/test/resources/jacoco-aggregate.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file