From 056d122d167155bd380ae9a7a92fd1b0e601035e Mon Sep 17 00:00:00 2001 From: Florian McKee <84742327+fmck3516@users.noreply.github.com> Date: Tue, 5 Dec 2023 19:52:39 -0600 Subject: [PATCH] issues/27: Prevent duplicate coverage builds if class files are duplicated across SourceSets --- skippy-gradle-sandbox/build.gradle | 11 ++ skippy-gradle-sandbox/gradle.properties | 2 +- .../java/io/skippy/gradle/AnalyzeTask.java | 39 +++--- .../java/io/skippy/gradle/DecoratedClass.java | 127 ------------------ .../io/skippy/gradle/SkippyConstants.java | 4 +- .../java/io/skippy/gradle/SkippyPlugin.java | 29 ++-- .../skippy/gradle/SkippyPluginExtension.java | 50 +++++++ .../gradle/asm/SkippyJUnit5Detector.java | 2 +- .../gradle/collector/ClassFileCollector.java | 85 ++++++++++++ .../collector/SkippifiedTestCollector.java | 59 ++++++++ .../io/skippy/gradle/model/ClassFile.java | 85 ++++++++++++ .../skippy/gradle/model/SkippifiedTest.java | 52 +++++++ .../gradle/model/SourceSetWithTestTask.java | 77 +++++++++++ .../java/io/skippy/gradle/util/Projects.java | 80 ----------- .../io/skippy/gradle/util/SourceSets.java | 74 ---------- .../java/io/skippy/gradle/util/Tasks.java | 86 ------------ ...ratedClassTest.java => ClassFileTest.java} | 24 +--- .../gradle/asm/SkippyJunit5DetectorTest.java | 2 +- 18 files changed, 470 insertions(+), 418 deletions(-) delete mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/DecoratedClass.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/collector/ClassFileCollector.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/collector/SkippifiedTestCollector.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/model/ClassFile.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/model/SkippifiedTest.java create mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/model/SourceSetWithTestTask.java delete mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/util/Projects.java delete mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/util/SourceSets.java delete mode 100644 skippy-gradle/src/main/java/io/skippy/gradle/util/Tasks.java rename skippy-gradle/src/test/java/io/skippy/gradle/{DecoratedClassTest.java => ClassFileTest.java} (61%) diff --git a/skippy-gradle-sandbox/build.gradle b/skippy-gradle-sandbox/build.gradle index 4dcedfb..8c764cf 100644 --- a/skippy-gradle-sandbox/build.gradle +++ b/skippy-gradle-sandbox/build.gradle @@ -10,6 +10,17 @@ plugins { apply plugin: io.skippy.gradle.SkippyPlugin; +skippy { + sourceSet { + name = 'test' + testTask = 'test' + } + sourceSet { + name = 'intTest' + testTask = 'integrationTest' + } +} + repositories { mavenCentral() maven { url = 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } diff --git a/skippy-gradle-sandbox/gradle.properties b/skippy-gradle-sandbox/gradle.properties index 108c82a..dc33060 100644 --- a/skippy-gradle-sandbox/gradle.properties +++ b/skippy-gradle-sandbox/gradle.properties @@ -1 +1 @@ -org.gradle.logging.level=info \ No newline at end of file +org.gradle.logging.level=lifecycle \ No newline at end of file diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/AnalyzeTask.java b/skippy-gradle/src/main/java/io/skippy/gradle/AnalyzeTask.java index eb724d4..8508652 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/AnalyzeTask.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/AnalyzeTask.java @@ -16,8 +16,10 @@ package io.skippy.gradle; +import io.skippy.gradle.collector.ClassFileCollector; +import io.skippy.gradle.collector.SkippifiedTestCollector; +import io.skippy.gradle.model.SkippifiedTest; import org.gradle.api.DefaultTask; -import org.gradle.api.Project; import org.gradle.api.UncheckedIOException; import org.gradle.tooling.BuildLauncher; import org.gradle.tooling.GradleConnector; @@ -26,7 +28,6 @@ import javax.inject.Inject; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.ArrayList; import static io.skippy.gradle.SkippyConstants.SKIPPY_ANALYSIS_FILES_TXT; import static io.skippy.gradle.SkippyConstants.SKIPPY_DIRECTORY; @@ -46,30 +47,26 @@ class AnalyzeTask extends DefaultTask { * Comment to make the JavaDoc task happy. */ @Inject - public AnalyzeTask() { + public AnalyzeTask(ClassFileCollector classCollector, SkippifiedTestCollector skippifiedTestCollector) { setGroup("skippy"); - var dependencies = new ArrayList(); - dependencies.add("classes"); - dependencies.add("testClasses"); - dependencies.add("skippyClean"); - setDependsOn(dependencies); + dependsOn("skippyClean"); doLast((task) -> { - createCoverageReportsForSkippifiedTests(getProject()); - createAnalyzedFilesTxt(getProject()); + createCoverageReportsForSkippifiedTests(skippifiedTestCollector); + createAnalyzedFilesTxt(classCollector); }); } - private void createCoverageReportsForSkippifiedTests(Project project) { + private void createCoverageReportsForSkippifiedTests(SkippifiedTestCollector skippifiedTestCollector) { GradleConnector connector = GradleConnector.newConnector(); connector.forProjectDirectory(getProject().getProjectDir()); try (ProjectConnection connection = connector.connect()) { - for (var skippifiedTest : DecoratedClass.fromAllSkippifiedTestsIn(project)) { + for (var skippifiedTest : skippifiedTestCollector.collectAllIn(getProject())) { runCoverageBuild(connection, skippifiedTest); } } } - private void runCoverageBuild(ProjectConnection connection, DecoratedClass skippifiedTest) { + private void runCoverageBuild(ProjectConnection connection, SkippifiedTest skippifiedTest) { BuildLauncher buildLauncher = connection.newBuild(); var errorOutputStream = new ByteArrayOutputStream(); buildLauncher.setStandardError(errorOutputStream); @@ -96,9 +93,13 @@ private void runCoverageBuild(ProjectConnection connection, DecoratedClass skipp } } - private void configureCoverageBuild(BuildLauncher build, DecoratedClass skippifiedTest) { - var tasks = asList(skippifiedTest.getTestTask().getName(), "jacocoTestReport"); - var arguments = asList("-PskippyCoverageBuild=true", "-PskippyClassFile=" + skippifiedTest.getAbsolutePath()); + private void configureCoverageBuild(BuildLauncher build, SkippifiedTest skippifiedTest) { + var tasks = asList(skippifiedTest.getTestTask(), "jacocoTestReport"); + var arguments = asList( + "-PskippyCoverageBuild=true", + "-PskippyClassFile=" + skippifiedTest.getAbsolutePath(), + "-PskippyTestTask=" + skippifiedTest.getTestTask() + ); build.forTasks(tasks.toArray(new String[0])); build.addArguments(arguments.toArray(new String[0])); if (getLogging().getLevel() != null) { @@ -118,12 +119,12 @@ private void configureCoverageBuild(BuildLauncher build, DecoratedClass skippifi getLogger().lifecycle("%s".formatted(skippifiedTest.getFullyQualifiedClassName())); } - private void createAnalyzedFilesTxt(Project project) { + private void createAnalyzedFilesTxt(ClassFileCollector classCollector) { try { var skippyAnalysisFile = getProject().getProjectDir().toPath().resolve(SKIPPY_DIRECTORY).resolve(SKIPPY_ANALYSIS_FILES_TXT); skippyAnalysisFile.toFile().createNewFile(); - var classFiles = DecoratedClass.fromAllClassesIn(project); - getLogger().lifecycle("\nCreating the Skippy analysis file %s.".formatted(project.getProjectDir().toPath().relativize(skippyAnalysisFile))); + getLogger().lifecycle("\nCreating the Skippy analysis file %s.".formatted(getProject().getProjectDir().toPath().relativize(skippyAnalysisFile))); + var classFiles = classCollector.collectAllInProject(getProject()); writeString(skippyAnalysisFile, classFiles.stream() .map(classFile -> "%s:%s".formatted(classFile.getRelativePath(), classFile.getHash())) .collect(joining(lineSeparator()))); diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/DecoratedClass.java b/skippy-gradle/src/main/java/io/skippy/gradle/DecoratedClass.java deleted file mode 100644 index 5c4752b..0000000 --- a/skippy-gradle/src/main/java/io/skippy/gradle/DecoratedClass.java +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.skippy.gradle; - -import io.skippy.gradle.asm.ClassNameExtractor; -import io.skippy.gradle.asm.DebugAgnosticHash; -import io.skippy.gradle.asm.SkippyJUnit5Detector; -import io.skippy.gradle.util.Projects; -import io.skippy.gradle.util.Tasks; -import org.gradle.api.Project; -import org.gradle.api.tasks.testing.Test; - -import java.nio.file.Path; -import java.util.List; - -import static java.util.Comparator.comparing; - -/** - * Wrapper that adds a bunch of functionality on top of a class file. - * - * @author Florian McKee - */ -final class DecoratedClass { - - private final Project project; - private final Path classFile; - - /** - * C'tor. - * - * @param project the Gradle {@link Project} - * @param classFile the class file in the file system (e.g., /user/johndoe/repos/demo/build/classes/java/main/com/example/Foo.class) - */ - DecoratedClass(Project project, Path classFile) { - this.project = project; - this.classFile = classFile; - } - - static List fromAllClassesIn(Project project) { - var classFiles = Projects.findAllClassFiles(project); - return classFiles.stream() - .map(classFile -> new DecoratedClass(project, classFile)) - .sorted(comparing(DecoratedClass::getFullyQualifiedClassName)) - .toList(); - } - - static List fromAllSkippifiedTestsIn(Project project) { - return fromAllClassesIn(project).stream() - .filter(clazz -> SkippyJUnit5Detector.usesSkippyExtension(clazz.getAbsolutePath())) - .toList(); - } - - /** - * Returns the fully qualified class name (e.g., com.example.Foo). - * - * @return the fully qualified class name (e.g., com.example.Foo) - */ - String getFullyQualifiedClassName() { - return ClassNameExtractor.getFullyQualifiedClassName(classFile); - } - - - /** - * Returns the absolute {@link Path} of the class file. - * - * @return the absolute {@link Path} of the class file. - */ - Path getAbsolutePath() { - return classFile; - } - - /** - * Returns the relative {@link Path} of the class file relative to the project root, - * (e.g., src/main/java/com/example/Foo.java) - * - * @return the relative {@link Path} of the class file relative to the project root, - * (e.g., src/main/java/com/example/Foo.java) - */ - Path getRelativePath() { - return project.getProjectDir().toPath().relativize(classFile); - } - - /** - * Returns a hash of the class file in BASE64 encoding. - * - * @return a hash of the class file in BASE64 encoding - */ - String getHash() { - return DebugAgnosticHash.hash(classFile); - } - - /** - * Returns {@code true} if this class is the test that uses the Skippy extension, {@code false} otherwise. - * - * @return {@code true} if this class is the test that uses the Skippy extension, {@code false} otherwise - */ - boolean usesSkippyExtension() { - return SkippyJUnit5Detector.usesSkippyExtension(classFile); - } - - /** - * Returns the {@link Test} task that runs this class (assuming it is a test). - * - * @return the {@link Test} task that runs this class (assuming it is a test) - */ - Test getTestTask() { - if ( ! usesSkippyExtension()) { - throw new UnsupportedOperationException("The testTask property is only available for skippified tests."); - } - return Tasks.getTestTaskFor(project, classFile); - } - -} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyConstants.java b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyConstants.java index d87780e..12b266e 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyConstants.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyConstants.java @@ -16,6 +16,8 @@ package io.skippy.gradle; +import io.skippy.gradle.model.ClassFile; + import java.nio.file.Path; /** @@ -31,7 +33,7 @@ public final class SkippyConstants { public static final Path SKIPPY_DIRECTORY = Path.of("skippy"); /** - * The file that contains data for all {@link DecoratedClass}s. + * The file that contains data for all {@link ClassFile}s. */ public static final Path SKIPPY_ANALYSIS_FILES_TXT = Path.of("analyzedFiles.txt"); diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPlugin.java b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPlugin.java index e257ace..23bb072 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPlugin.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPlugin.java @@ -16,6 +16,10 @@ package io.skippy.gradle; +import io.skippy.gradle.collector.ClassFileCollector; +import io.skippy.gradle.collector.SkippifiedTestCollector; +import io.skippy.gradle.model.ClassFile; +import io.skippy.gradle.model.SkippifiedTest; import org.gradle.api.Project; import org.gradle.api.plugins.JavaPlugin; import org.gradle.api.tasks.SourceSet; @@ -40,6 +44,7 @@ public final class SkippyPlugin implements org.gradle.api.Plugin { @Override public void apply(Project project) { project.getPlugins().apply(JavaPlugin.class); + project.getExtensions().create("skippy", SkippyPluginExtension.class); var isSkippyCoverageBuild = project.hasProperty("skippyCoverageBuild"); @@ -47,10 +52,16 @@ public void apply(Project project) { // add skippy tasks to the regular build project.getTasks().register("skippyClean", CleanTask.class); - project.getTasks().register("skippyAnalyze", AnalyzeTask.class); + + var classFileCollector = new ClassFileCollector(); + var skippifiedTestCollector = new SkippifiedTestCollector(classFileCollector); + + project.getTasks().register("skippyAnalyze", AnalyzeTask.class, classFileCollector, skippifiedTestCollector); } else { - var testClass = new DecoratedClass(project, Path.of(String.valueOf(project.property("skippyClassFile")))); + var classFile = Path.of(String.valueOf(project.property("skippyClassFile"))); + var testTaskName = String.valueOf(project.property("skippyTestTask")); + var testClass = new SkippifiedTest(new ClassFile(project, classFile), testTaskName); // modify test and jacocoTestReport tasks in the skippyCoverage builds project.getPlugins().apply(JacocoPlugin.class); @@ -59,25 +70,25 @@ public void apply(Project project) { } } - private static void modifyTestTask(Project project, DecoratedClass testClass) { + private static void modifyTestTask(Project project, SkippifiedTest skippifiedTest) { project.getTasks().withType(Test.class, test -> { - test.filter((filter) -> filter.includeTestsMatching(testClass.getFullyQualifiedClassName())); + test.filter((filter) -> filter.includeTestsMatching(skippifiedTest.getFullyQualifiedClassName())); test.getExtensions().configure(JacocoTaskExtension.class, jacoco -> { - jacoco.setDestinationFile(project.file(project.getProjectDir() + "/skippy/" + testClass.getFullyQualifiedClassName() + ".exec")); + jacoco.setDestinationFile(project.file(project.getProjectDir() + "/skippy/" + skippifiedTest.getFullyQualifiedClassName() + ".exec")); }); }); } - private static void modifyJacocoTestReportTask(Project project, DecoratedClass testClass) { + private static void modifyJacocoTestReportTask(Project project, SkippifiedTest skippifiedTest) { project.afterEvaluate(action -> { - var testTask = testClass.getTestTask().getName(); + var testTask = skippifiedTest.getTestTask(); project.getTasks().named("jacocoTestReport", JacocoReport.class, jacoco -> { jacoco.setDependsOn(asList(testTask)); jacoco.reports(reports -> { reports.getXml().getRequired().set(Boolean.FALSE); reports.getCsv().getRequired().set(Boolean.TRUE); - reports.getHtml().getOutputLocation().set(project.file(project.getBuildDir() + "/jacoco/html/" + testClass.getFullyQualifiedClassName())); - var csvFile = project.getProjectDir().toPath().resolve(SKIPPY_DIRECTORY).resolve(testClass.getFullyQualifiedClassName() + ".csv"); + reports.getHtml().getOutputLocation().set(project.file(project.getBuildDir() + "/jacoco/html/" + skippifiedTest.getFullyQualifiedClassName())); + var csvFile = project.getProjectDir().toPath().resolve(SKIPPY_DIRECTORY).resolve(skippifiedTest.getFullyQualifiedClassName() + ".csv"); reports.getCsv().getOutputLocation().set(project.file(csvFile)); }); // capture coverage for all source sets diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java new file mode 100644 index 0000000..e0f984a --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle; + +import io.skippy.gradle.model.SourceSetWithTestTask; +import org.gradle.api.Action; + +import java.util.ArrayList; +import java.util.List; + +import static org.codehaus.groovy.runtime.InvokerHelper.asList; + +/** + * Extension that allows for customization of {@link AnalyzeTask}. + * + * @author Florian McKee + */ +public class SkippyPluginExtension { + + private List sourceSetsWithTestTask = new ArrayList<>(); + + public List getSourceSetsWithTestTasks() { + if (sourceSetsWithTestTask.isEmpty()) { + return asList(new SourceSetWithTestTask("test", "test")); + } + return sourceSetsWithTestTask; + } + + public void sourceSet(Action action) { + var sourceSetAndTestTask = new SourceSetWithTestTask(null, null); + sourceSetsWithTestTask.add(sourceSetAndTestTask); + action.execute(sourceSetAndTestTask); + } + + +} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/asm/SkippyJUnit5Detector.java b/skippy-gradle/src/main/java/io/skippy/gradle/asm/SkippyJUnit5Detector.java index a10fe9c..d0d45cd 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/asm/SkippyJUnit5Detector.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/asm/SkippyJUnit5Detector.java @@ -38,7 +38,7 @@ public final class SkippyJUnit5Detector { * @return {@code true} if the {@code classFile} is annotated with the Skippy JUnit 5 Extension, {@code false} * otherwise */ - public static boolean usesSkippyExtension(Path classFile) { + public static boolean usesSkippyJunit5Extension(Path classFile) { var usesSkippyJunit5Extension = new AtomicBoolean(false); try (var inputStream = new FileInputStream(classFile.toFile())) { new ClassReader(inputStream).accept(createClassVisitor(usesSkippyJunit5Extension), 0); diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/collector/ClassFileCollector.java b/skippy-gradle/src/main/java/io/skippy/gradle/collector/ClassFileCollector.java new file mode 100644 index 0000000..d681e9f --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/collector/ClassFileCollector.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle.collector; + +import io.skippy.gradle.model.ClassFile; +import org.gradle.api.Project; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.SourceSetContainer; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import static java.util.Comparator.comparing; + +/** + * Collects {@link ClassFile}s in the project's output directories. + */ +public final class ClassFileCollector { + + /** + * Collects all {@link ClassFile}s in the output directories the {@param project}. + * + * @return all {@link ClassFile}s in the output directories the {@param project} + */ + public List collectAllInProject(Project project) { + var result = new ArrayList(); + var sourceSetContainer = project.getExtensions().getByType(SourceSetContainer.class); + for (var sourceSet : sourceSetContainer) { + result.addAll(collectAllInSourceSet(project, sourceSet)); + } + return sort(result); + } + + /** + * Collects all {@link ClassFile}s in the output directories of the {@param sourceSet}. + * + * @return all {@link ClassFile}s in the output directories of the {@param sourceSet} + */ + List collectAllInSourceSet(Project project, SourceSet sourceSet) { + var classesDirs = sourceSet.getOutput().getClassesDirs().getFiles(); + var result = new ArrayList(); + for (var classesDir : classesDirs) { + result.addAll(collectAllInDirectory(project, classesDir)); + } + return sort(result); + } + + private static List collectAllInDirectory(Project project, File directory) { + var result = new LinkedList(); + File[] files = directory.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + result.addAll(collectAllInDirectory(project, file)); + } else if (file.getName().endsWith(".class")) { + result.add(new ClassFile(project, file.toPath())); + } + } + } + return result; + } + + private List sort(List input) { + return input.stream() + .sorted(comparing(ClassFile::getFullyQualifiedClassName)) + .toList(); + } + +} \ No newline at end of file diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/collector/SkippifiedTestCollector.java b/skippy-gradle/src/main/java/io/skippy/gradle/collector/SkippifiedTestCollector.java new file mode 100644 index 0000000..a470d9f --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/collector/SkippifiedTestCollector.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle.collector; + +import io.skippy.gradle.SkippyPluginExtension; +import io.skippy.gradle.asm.SkippyJUnit5Detector; +import io.skippy.gradle.model.SkippifiedTest; +import io.skippy.gradle.model.SourceSetWithTestTask; +import org.gradle.api.Project; +import org.gradle.api.tasks.SourceSetContainer; + +import java.util.LinkedList; +import java.util.List; + +import static java.util.Comparator.comparing; + +public final class SkippifiedTestCollector { + + private final ClassFileCollector classFileCollector; + + public SkippifiedTestCollector(ClassFileCollector classFileCollector) { + this.classFileCollector = classFileCollector; + } + + public List collectAllIn(Project project) { + var result = new LinkedList(); + var skippyPluginExtension = project.getExtensions().getByType(SkippyPluginExtension.class); + for (var sourceSetWithTestTask : skippyPluginExtension.getSourceSetsWithTestTasks()) { + result.addAll(collectAllInSourceSet(project, sourceSetWithTestTask)); + } + return result; + } + + private List collectAllInSourceSet(Project project, SourceSetWithTestTask sourceSetWithTestTask) { + var sourceSetContainer = project.getExtensions().getByType(SourceSetContainer.class); + var sourceSet = sourceSetContainer.getByName(sourceSetWithTestTask.getSourceSetName()); + var classFiles = classFileCollector.collectAllInSourceSet(project, sourceSet); + return classFiles.stream() + .filter(classFile -> SkippyJUnit5Detector.usesSkippyJunit5Extension(classFile.getAbsolutePath())) + .map(classFile -> new SkippifiedTest(classFile, sourceSetWithTestTask.getTestTask())) + .sorted(comparing(skippifiedTest -> skippifiedTest.getFullyQualifiedClassName())) + .toList(); + } + +} \ No newline at end of file diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/model/ClassFile.java b/skippy-gradle/src/main/java/io/skippy/gradle/model/ClassFile.java new file mode 100644 index 0000000..ee51112 --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/model/ClassFile.java @@ -0,0 +1,85 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle.model; + +import io.skippy.gradle.asm.ClassNameExtractor; +import io.skippy.gradle.asm.DebugAgnosticHash; +import org.gradle.api.Project; + +import java.nio.file.Path; + +/** + * Thin wrapper around a class file that adds a couple of convenience methods. + * + * @author Florian McKee + */ +public final class ClassFile { + + private final Project project; + private final Path classFile; + + /** + * C'tor. + * + * @param project the Gradle {@link Project} + * @param classFile the class file in the file system (e.g., /user/johndoe/repos/demo/build/classes/java/main/com/example/Foo.class) + */ + public ClassFile(Project project, Path classFile) { + this.project = project; + this.classFile = classFile; + } + + /** + * Returns the fully qualified class name (e.g., com.example.Foo). + * + * @return the fully qualified class name (e.g., com.example.Foo) + */ + public String getFullyQualifiedClassName() { + return ClassNameExtractor.getFullyQualifiedClassName(classFile); + } + + + /** + * Returns the absolute {@link Path} of the class file. + * + * @return the absolute {@link Path} of the class file. + */ + public Path getAbsolutePath() { + return classFile; + } + + /** + * Returns the relative {@link Path} of the class file relative to the project root, + * (e.g., src/main/java/com/example/Foo.java) + * + * @return the relative {@link Path} of the class file relative to the project root, + * (e.g., src/main/java/com/example/Foo.java) + */ + public Path getRelativePath() { + return project.getProjectDir().toPath().relativize(classFile); + } + + /** + * Returns a hash of the contents of the class file. + * + * @return a hash of the contents of the class file + */ + public String getHash() { + return DebugAgnosticHash.hash(classFile); + } + +} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/model/SkippifiedTest.java b/skippy-gradle/src/main/java/io/skippy/gradle/model/SkippifiedTest.java new file mode 100644 index 0000000..3c80568 --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/model/SkippifiedTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle.model; + +import java.nio.file.Path; + +/** + * Thin wrapper around a skippified test that stores + *
    + *
  • the test's {@link ClassFile} and
  • + *
  • the name of the test task used to execute the test (e.g., integrationTest).
  • + *
+ * + * @author Florian McKee + */ +public class SkippifiedTest { + + private final ClassFile testClassFile; + private final String testTask; + + public SkippifiedTest(ClassFile test, String testTask) { + this.testClassFile = test; + this.testTask = testTask; + } + + public String getFullyQualifiedClassName() { + return testClassFile.getFullyQualifiedClassName(); + } + + public Path getAbsolutePath() { + return testClassFile.getAbsolutePath(); + } + + public String getTestTask() { + return testTask; + } + +} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/model/SourceSetWithTestTask.java b/skippy-gradle/src/main/java/io/skippy/gradle/model/SourceSetWithTestTask.java new file mode 100644 index 0000000..0cf8a1d --- /dev/null +++ b/skippy-gradle/src/main/java/io/skippy/gradle/model/SourceSetWithTestTask.java @@ -0,0 +1,77 @@ +/* + * Copyright 2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.gradle.model; + +import io.skippy.gradle.SkippyPluginExtension; +import org.gradle.api.tasks.SourceSet; +import org.gradle.api.tasks.testing.Test; + +/** + * A 2-tuple containing + *
    + *
  • the name of a {@link SourceSet} and
  • + *
  • the name of the corresponding {@link Test} task.
  • + *
+ * The {@link SkippyPluginExtension} uses this class to store customizations: + *
+ * skippy {
+ *     sourceSet {
+ *         name = 'test'
+ *         testTask = 'test'
+ *     }
+ *     sourceSet {
+ *         name = 'intTest'
+ *         testTask = 'integrationTest'
+ *     }
+ * }
+ * 
+ */ +public class SourceSetWithTestTask { + + private String name; + private String testTask; + + /** + * C'tor. + * + * @param sourceSetName the name of a {@link SourceSet} + * @param testTask the name of the {@link Test} task to execute tests in the {@link SourceSet} + */ + public SourceSetWithTestTask(String sourceSetName, String testTask) { + this.name = sourceSetName; + this.testTask = testTask; + } + + /** + * Returns the name of a {@link SourceSet}. + * + * @return the name of a {@link SourceSet} + */ + public String getSourceSetName() { + return name; + } + + /** + * Returns the name of the {@link Test} task to execute tests in the {@link SourceSet}. + * + * @return the name of the {@link Test} task to execute tests in the {@link SourceSet} + */ + public String getTestTask() { + return testTask; + } + +} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/util/Projects.java b/skippy-gradle/src/main/java/io/skippy/gradle/util/Projects.java deleted file mode 100644 index 338cf83..0000000 --- a/skippy-gradle/src/main/java/io/skippy/gradle/util/Projects.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.skippy.gradle.util; - -import io.skippy.gradle.Profiler; -import org.gradle.api.Project; -import org.gradle.api.tasks.SourceSet; -import org.gradle.api.tasks.SourceSetContainer; - -import java.io.File; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; - -import static io.skippy.gradle.Profiler.profile; - -/** - * Utility methods that return / operate on to Gradle {@link org.gradle.api.Project}s. - * - * @author Florian McKee - */ -public final class Projects { - - /** - * Collects all class files in the {@code project}. - * - * @param project a {@link Project} - * @return a list of class files - */ - public static List findAllClassFiles(Project project) { - return profile(Projects.class, "findAllClassFiles", () -> { - var result = new ArrayList(); - var sourceSetContainer = project.getExtensions().getByType(SourceSetContainer.class); - for (var sourceSet : sourceSetContainer) { - result.addAll(findAllClassFilesInSourceSet(sourceSet)); - } - return result; - }); - } - - private static List findAllClassFilesInSourceSet(SourceSet sourceSet) { - var classesDirs = sourceSet.getOutput().getClassesDirs().getFiles(); - var result = new ArrayList(); - for (var classesDir : classesDirs) { - result.addAll(findAllClassFilesInDirectory(classesDir)); - } - return result; - } - - private static List findAllClassFilesInDirectory(File directory) { - var result = new LinkedList(); - File[] files = directory.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - result.addAll(findAllClassFilesInDirectory(file)); - } else if (file.getName().endsWith(".class")) { - result.add(file.toPath()); - } - } - } - return result; - } - -} \ No newline at end of file diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/util/SourceSets.java b/skippy-gradle/src/main/java/io/skippy/gradle/util/SourceSets.java deleted file mode 100644 index 6bcd898..0000000 --- a/skippy-gradle/src/main/java/io/skippy/gradle/util/SourceSets.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.skippy.gradle.util; - -import org.gradle.api.Project; -import org.gradle.api.file.FileCollection; -import org.gradle.api.tasks.SourceSet; -import org.gradle.api.tasks.SourceSetContainer; - -import java.io.File; -import java.nio.file.Path; - -/** - * Utility methods that return / operate on to Gradle {@link SourceSet}s. - * - * @author Florian McKee - */ -final class SourceSets { - - /** - * Finds the {@link SourceSet} in the given {@code project} that contains the {@code classFile} in one of its - * output directories. - * - *

- * - * Let's assume you have the test source set in src/test and another, custom source set - * intTest for integration tests that is declared as follows: - *
-     * sourceSets {
-     *     intTest {
-     *         ...
-     *     }
-     * }
-     * 
- * - * This method will - *
    - *
  • return test if the class is located in build/classes/java/test
  • - *
  • return intTest if the class is located in build/classes/java/intTest
  • - *
- * - * @param project the Gradle {@link Project} - * @param classFile a class file - * @return the {@link SourceSet} in the given {@code project} that contains the {@code classFile} in one of its - * output directories - */ - static SourceSet findSourceSetContaining(Project project, Path classFile) { - SourceSetContainer sourceSetContainer = project.getExtensions().getByType(SourceSetContainer.class); - for (SourceSet sourceSet : sourceSetContainer) { - FileCollection classesDirs = sourceSet.getOutput().getClassesDirs(); - for (File classesDir : classesDirs.getFiles()) { - if (classFile.startsWith(classesDir.toPath())) { - return sourceSet; - } - } - } - throw new RuntimeException("Unable to determine SourceSet for class '%s'.".formatted(classFile)); - } - -} \ No newline at end of file diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/util/Tasks.java b/skippy-gradle/src/main/java/io/skippy/gradle/util/Tasks.java deleted file mode 100644 index ab9f632..0000000 --- a/skippy-gradle/src/main/java/io/skippy/gradle/util/Tasks.java +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.skippy.gradle.util; - -import org.gradle.api.Project; -import org.gradle.api.tasks.SourceSet; -import org.gradle.api.tasks.testing.Test; - -import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicReference; - -import static io.skippy.gradle.util.SourceSets.findSourceSetContaining; - -/** - * Utility methods that return / operate on to Gradle {@link org.gradle.api.Task}s. - * - * @author Florian McKee - */ -public final class Tasks { - - /** - * Returns the {@link Test} task that runs the {@code testClass}. - * - *

- * - * Let's assume you have the test source set in src/test and another, custom source set - * intTest for integration tests that is declared as follows: - *
-     * sourceSets {
-     *     intTest {
-     *         compileClasspath += sourceSets.main.output
-     *         runtimeClasspath += sourceSets.main.output
-     *     }
-     * }
-     * 
- * Additionally, the project registers the task integrationTest to run the tests in intTest: - *
-     * tasks.register('integrationTest', Test) {
-     *     testClassesDirs = sourceSets.intTest.output.classesDirs
-     *     classpath = sourceSets.intTest.runtimeClasspath
-     * }
-     * 
- * - * This method will - *
    - *
  • return test if the test is located in build/classes/java/test
  • - *
  • return integrationTest if the test is located in build/classes/java/intTest
  • - *
- * - * @param project the Gradle {@link Project} - * @param testClass the class file that contains a JUnit test - * @return the {@link Test} task that runs the {@code testClass} - */ - public static Test getTestTaskFor(Project project, Path testClass) { - var sourceSet = findSourceSetContaining(project, testClass); - var testTaskRef = new AtomicReference(); - project.getTasks().withType(Test.class, testTask -> { - if (isTestTaskForSourceSet(testTask, sourceSet)) { - testTaskRef.set(testTask); - } - }); - if (testTaskRef.get() == null) { - throw new RuntimeException("Unable to determine test task for '%s'.".formatted(testClass)); - } - return testTaskRef.get(); - } - - private static boolean isTestTaskForSourceSet(Test testTask, SourceSet sourceSet) { - return sourceSet.getOutput().getClassesDirs().getFiles().containsAll(testTask.getTestClassesDirs().getFiles()); - } - -} \ No newline at end of file diff --git a/skippy-gradle/src/test/java/io/skippy/gradle/DecoratedClassTest.java b/skippy-gradle/src/test/java/io/skippy/gradle/ClassFileTest.java similarity index 61% rename from skippy-gradle/src/test/java/io/skippy/gradle/DecoratedClassTest.java rename to skippy-gradle/src/test/java/io/skippy/gradle/ClassFileTest.java index 7bc44ee..1970602 100644 --- a/skippy-gradle/src/test/java/io/skippy/gradle/DecoratedClassTest.java +++ b/skippy-gradle/src/test/java/io/skippy/gradle/ClassFileTest.java @@ -16,6 +16,7 @@ package io.skippy.gradle; +import io.skippy.gradle.model.ClassFile; import org.gradle.api.Project; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -27,27 +28,12 @@ import static org.mockito.Mockito.mock; /** - * Tests for {@link DecoratedClass}. + * Tests for {@link ClassFile}. * * @author Florian McKee */ -public class DecoratedClassTest { +public class ClassFileTest { - @ParameterizedTest - @CsvSource(value = { - "decoratedclass/SourceFileTest1.class:true", - "decoratedclass/SourceFileTest2.class:true", - "decoratedclass/SourceFileTest3.class:true", - "decoratedclass/SourceFileTest4.class:true", - "decoratedclass/SourceFileTest5.class:false", - "decoratedclass/SourceFileTest6.class:false", - "decoratedclass/SourceFileTest7.class:false", - "decoratedclass/SourceFileTest8.class:false" - }, delimiter = ':') - void testUsesSkippyExtension(String fileName, boolean expectedValue) throws URISyntaxException { - var classFile = Paths.get(getClass().getResource(fileName).toURI()); - assertEquals(expectedValue, new DecoratedClass(mock(Project.class), classFile).usesSkippyExtension()); - } @ParameterizedTest @CsvSource(value = { "decoratedclass/SourceFileTest1.class:com.example.SourceFileTest1", @@ -55,7 +41,7 @@ void testUsesSkippyExtension(String fileName, boolean expectedValue) throws URIS }, delimiter = ':') void testGetFullyQualifiedClassName(String fileName, String expectedValue) throws URISyntaxException { var classFile = Paths.get(getClass().getResource(fileName).toURI()); - assertEquals(expectedValue, new DecoratedClass(mock(Project.class), classFile).getFullyQualifiedClassName()); + assertEquals(expectedValue, new ClassFile(mock(Project.class), classFile).getFullyQualifiedClassName()); } @ParameterizedTest @@ -65,7 +51,7 @@ void testGetFullyQualifiedClassName(String fileName, String expectedValue) throw }, delimiter = ':') void getHash(String fileName, String expectedValue) throws URISyntaxException { var classFile = Paths.get(getClass().getResource(fileName).toURI()); - assertEquals(expectedValue, new DecoratedClass(mock(Project.class), classFile).getHash()); + assertEquals(expectedValue, new ClassFile(mock(Project.class), classFile).getHash()); } } \ No newline at end of file diff --git a/skippy-gradle/src/test/java/io/skippy/gradle/asm/SkippyJunit5DetectorTest.java b/skippy-gradle/src/test/java/io/skippy/gradle/asm/SkippyJunit5DetectorTest.java index f97d6fb..591e757 100644 --- a/skippy-gradle/src/test/java/io/skippy/gradle/asm/SkippyJunit5DetectorTest.java +++ b/skippy-gradle/src/test/java/io/skippy/gradle/asm/SkippyJunit5DetectorTest.java @@ -39,7 +39,7 @@ public class SkippyJunit5DetectorTest { }, delimiter = ':') void testUsesSkippyExtension(String fileName, boolean expected) throws URISyntaxException { var classFile = Paths.get(getClass().getResource(fileName).toURI()); - assertEquals(expected, SkippyJUnit5Detector.usesSkippyExtension(classFile)); + assertEquals(expected, SkippyJUnit5Detector.usesSkippyJunit5Extension(classFile)); } } \ No newline at end of file