├── skippy-core ├── src │ ├── test │ │ ├── resources │ │ │ └── io │ │ │ │ └── skippy │ │ │ │ └── core │ │ │ │ ├── project │ │ │ │ └── .gitkeep │ │ │ │ ├── com.example.LeftPadderTest.exec │ │ │ │ ├── com.example.RightPadderTest.exec │ │ │ │ └── com.example.LeftPadderTest-run2.exec │ │ └── java │ │ │ ├── com │ │ │ └── example │ │ │ │ ├── Bar.java │ │ │ │ ├── Foo.java │ │ │ │ ├── BarTest.java │ │ │ │ ├── FooTest.java │ │ │ │ ├── ClassA.java │ │ │ │ ├── ClassB.java │ │ │ │ ├── ClassD.java │ │ │ │ ├── LeftPadder.java │ │ │ │ ├── ClassC.java │ │ │ │ ├── LeftPadderTest.java │ │ │ │ ├── StringUtils.java │ │ │ │ └── NestedTestsTest.java │ │ │ └── io │ │ │ └── skippy │ │ │ └── core │ │ │ ├── DefaultPredictionModifierTest.java │ │ │ ├── HashUtilTest.java │ │ │ ├── TestImpactAnalysisParsePerformanceTest.java │ │ │ ├── ClassUtilTest.java │ │ │ ├── TokenizerTest.java │ │ │ ├── JacocoExecutionDataUtilTest.java │ │ │ ├── SkippyConfigurationTest.java │ │ │ ├── ClassFileTest.java │ │ │ ├── AnalyzedTestTest.java │ │ │ └── CustomRepositoryExtensionTest.java │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── core │ │ ├── ClassNameAndPrediction.java │ │ ├── Prediction.java │ │ ├── ClassFileCollector.java │ │ ├── SkippyConstants.java │ │ ├── PredictionWithReason.java │ │ ├── AlwaysRun.java │ │ ├── TestTag.java │ │ ├── PredictionModifier.java │ │ ├── TestRecording.java │ │ ├── ClassNameAndJaCoCoId.java │ │ ├── SkippyFolder.java │ │ ├── DefaultPredictionModifier.java │ │ ├── SkippyRepositoryExtension.java │ │ ├── Reason.java │ │ ├── Tokenizer.java │ │ ├── ClassUtil.java │ │ ├── HashUtil.java │ │ ├── Profiler.java │ │ ├── SkippyBuildApi.java │ │ ├── JacocoUtil.java │ │ └── SkippyConfiguration.java ├── README.md └── build.gradle ├── skippy-gradle-android ├── src │ ├── test │ │ ├── java │ │ │ └── io │ │ │ │ └── skippy │ │ │ │ └── gradle │ │ │ │ └── android │ │ │ │ └── .gitkeep │ │ └── resources │ │ │ └── io │ │ │ └── skippy │ │ │ └── gradle │ │ │ └── android │ │ │ └── .gitkeep │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── gradle │ │ └── android │ │ ├── AndroidDestinationDirectoryCollector.java │ │ ├── SkippyAnalyzeTask.java │ │ ├── SkippyCleanTask.java │ │ ├── KotlinDestinationDirectoryCollector.java │ │ ├── SkippyPlugin.java │ │ ├── SkippyPluginExtension.java │ │ ├── ProjectSettings.java │ │ └── GradleClassFileCollector.java ├── README.md └── build.gradle ├── skippy-in-3-mins.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── skippy-extensions ├── skippy-repository-filesystem │ ├── README.md │ ├── build.gradle │ └── src │ │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── extension │ │ └── FileSystemBackedRepositoryExtension.java ├── skippy-repository-regression-suite │ ├── README.md │ ├── build.gradle │ └── src │ │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── extension │ │ └── RegressionSuiteRepositoryExtension.java └── skippy-prediction-modifier-regression-suite │ ├── README.md │ ├── src │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── extension │ │ └── RegressionSuitePredictionModifier.java │ └── build.gradle ├── skippy-junit4 ├── README.md ├── build.gradle └── src │ ├── main │ └── java │ │ └── io │ │ └── skippy │ │ └── junit4 │ │ ├── Skippy.java │ │ ├── SkipOrExecuteRule.java │ │ └── CoverageFileRule.java │ └── test │ └── java │ └── io │ └── skippy │ └── junit4 │ └── SkipOrExecuteRuleTest.java ├── skippy-junit5 ├── README.md ├── build.gradle └── src │ ├── main │ └── java │ │ └── io │ │ └── skippy │ │ └── junit5 │ │ ├── CoverageFileCallbacks.java │ │ ├── TestResultExtension.java │ │ ├── PredictWithSkippy.java │ │ └── SkipOrExecuteCondition.java │ └── test │ └── java │ └── io │ └── skippy │ └── junit5 │ └── SkipOrExecuteConditionTest.java ├── skippy-gradle ├── src │ ├── test │ │ ├── resources │ │ │ └── io │ │ │ │ └── skippy │ │ │ │ └── gradle │ │ │ │ ├── sourceset1 │ │ │ │ ├── NormalClass1.class │ │ │ │ └── SkippifiedTest1.class │ │ │ │ ├── sourceset2 │ │ │ │ ├── NormalClass2.class │ │ │ │ └── SkippifiedTest2.class │ │ │ │ ├── sourceset3 │ │ │ │ ├── NormalClass3.class │ │ │ │ └── SkippifiedTest3.class │ │ │ │ └── build │ │ │ │ └── classes │ │ │ │ └── java │ │ │ │ ├── main │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ ├── LeftPadder.class │ │ │ │ │ ├── RightPadder.class │ │ │ │ │ └── StringUtils.class │ │ │ │ └── test │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ ├── TestConstants.class │ │ │ │ ├── LeftPadderTest.class │ │ │ │ └── RightPadderTest.class │ │ └── java │ │ │ └── io │ │ │ └── skippy │ │ │ └── gradle │ │ │ └── GradleClassFileCollectorTest.java │ └── main │ │ └── java │ │ └── io │ │ └── skippy │ │ └── gradle │ │ ├── SkippyAnalyzeTask.java │ │ ├── SkippyCleanTask.java │ │ ├── SkippyPlugin.java │ │ ├── SkippyPluginExtension.java │ │ ├── GradleClassFileCollector.java │ │ └── ProjectSettings.java ├── README.md └── build.gradle ├── skippy-maven ├── README.md ├── src │ ├── test │ │ └── java │ │ │ └── io │ │ │ └── skippy │ │ │ └── maven │ │ │ └── SkippyAnalyzeMojoTest.java │ └── main │ │ ├── resources │ │ └── META-INF │ │ │ └── maven │ │ │ └── io.skippy │ │ │ └── skippy-maven │ │ │ └── plugin-help.xml │ │ └── java │ │ └── io │ │ └── skippy │ │ └── maven │ │ ├── SkippyBuildStartedMojo.java │ │ ├── SkippyBuildFinishedMojo.java │ │ ├── SkippyCleanMojo.java │ │ └── MavenClassFileCollector.java └── build.gradle ├── versions.properties ├── settings.gradle ├── .gitignore ├── .github └── workflows │ ├── gradle-pr.yml │ ├── gradle.yml │ └── gradle-publish.yml ├── gradlew.bat └── README.md /skippy-core/src/test/resources/io/skippy/core/project/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skippy-gradle-android/src/test/java/io/skippy/gradle/android/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skippy-gradle-android/src/test/resources/io/skippy/gradle/android/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skippy-in-3-mins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-in-3-mins.png -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/Bar.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | public class Bar { 4 | } 5 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/Foo.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | public class Foo { 4 | } 5 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/BarTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | public class BarTest { 4 | } 5 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/FooTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | public class FooTest { 4 | } 5 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /skippy-core/README.md: -------------------------------------------------------------------------------- 1 | # skippy-core 2 | 3 | Internal library that contains common functionality for Skippy's test and build libraries. -------------------------------------------------------------------------------- /skippy-gradle-android/README.md: -------------------------------------------------------------------------------- 1 | # skippy-gradle-android 2 | 3 | The Skippy Android plugin provides Skippy support for Gradle based Android builds. -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-filesystem/README.md: -------------------------------------------------------------------------------- 1 | # skippy-repository-filesystem 2 | 3 | Sample repository extension that stores all data in the filesystem -------------------------------------------------------------------------------- /skippy-junit4/README.md: -------------------------------------------------------------------------------- 1 | # skippy-junit4 2 | 3 | Skippy's Predictive Test Selection for JUnit 4. 4 | 5 | Documentation: https://www.skippy.io/docs/#junit-4 6 | -------------------------------------------------------------------------------- /skippy-junit5/README.md: -------------------------------------------------------------------------------- 1 | # skippy-junit5 2 | 3 | Skippy's Predictive Test Selection for JUnit 5. 4 | 5 | Documentation: https://www.skippy.io/docs/#junit-5 6 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/ClassA.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | class ClassA { 4 | static String append(String input) { 5 | return input + "A"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/ClassB.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | class ClassB { 4 | static String append(String input) { 5 | return input + "B"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/ClassD.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | class ClassD { 4 | static String append(String input) { 5 | return input + "D"; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-regression-suite/README.md: -------------------------------------------------------------------------------- 1 | # skippy-repository-regression-suite 2 | 3 | Internal repository extension that is used by the tests in skippy-regression-suite. -------------------------------------------------------------------------------- /skippy-core/src/test/resources/io/skippy/core/com.example.LeftPadderTest.exec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-core/src/test/resources/io/skippy/core/com.example.LeftPadderTest.exec -------------------------------------------------------------------------------- /skippy-core/src/test/resources/io/skippy/core/com.example.RightPadderTest.exec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-core/src/test/resources/io/skippy/core/com.example.RightPadderTest.exec -------------------------------------------------------------------------------- /skippy-extensions/skippy-prediction-modifier-regression-suite/README.md: -------------------------------------------------------------------------------- 1 | # skippy-prediction-modifier-regression-suite 2 | 3 | Internal prediction modifier that is used by the tests in skippy-regression-suite. -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset1/NormalClass1.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset1/NormalClass1.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset2/NormalClass2.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset2/NormalClass2.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset3/NormalClass3.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset3/NormalClass3.class -------------------------------------------------------------------------------- /skippy-core/src/test/resources/io/skippy/core/com.example.LeftPadderTest-run2.exec: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-core/src/test/resources/io/skippy/core/com.example.LeftPadderTest-run2.exec -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset1/SkippifiedTest1.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset1/SkippifiedTest1.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset2/SkippifiedTest2.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset2/SkippifiedTest2.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/sourceset3/SkippifiedTest3.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/sourceset3/SkippifiedTest3.class -------------------------------------------------------------------------------- /skippy-maven/README.md: -------------------------------------------------------------------------------- 1 | # skippy-maven 2 | 3 | The Skippy plugin provides Skippy support for Maven. 4 | 5 | - Documentation: https://www.skippy.io/docs/#maven 6 | - Tutorial: https://www.skippy.io/tutorials/skippy-maven-junit5 -------------------------------------------------------------------------------- /skippy-gradle/README.md: -------------------------------------------------------------------------------- 1 | # skippy-gradle 2 | 3 | The Skippy plugin provides Skippy support for Gradle. 4 | 5 | - Documentation: https://www.skippy.io/docs/#gradle 6 | - Tutorial: https://www.skippy.io/tutorials/skippy-gradle-junit5 -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/LeftPadder.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/LeftPadder.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/RightPadder.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/RightPadder.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/StringUtils.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/main/com/example/StringUtils.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/TestConstants.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/TestConstants.class -------------------------------------------------------------------------------- /skippy-maven/src/test/java/io/skippy/maven/SkippyAnalyzeMojoTest.java: -------------------------------------------------------------------------------- 1 | package io.skippy.maven; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | public class SkippyAnalyzeMojoTest { 6 | 7 | @Test 8 | void testExecute() { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/LeftPadderTest.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/LeftPadderTest.class -------------------------------------------------------------------------------- /skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/RightPadderTest.class: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/skippy-io/skippy/HEAD/skippy-gradle/src/test/resources/io/skippy/gradle/build/classes/java/test/com/example/RightPadderTest.class -------------------------------------------------------------------------------- /versions.properties: -------------------------------------------------------------------------------- 1 | skippy=0.0.26-SNAPSHOT 2 | 3 | asm=9.7 4 | assertj:3.24.2 5 | jacoco=0.8.12 6 | junit4=4.13.2 7 | junit5=5.10.1 8 | maven=3.9.6 9 | maven-project=2.2.1 10 | maven-plugin-annotations=3.10.2 11 | mockito=5.11.0 12 | kotlin-gradle-plugin=2.0.21 -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/LeftPadder.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | class LeftPadder { 4 | LeftPadder() { 5 | } 6 | 7 | static String padLeft(String input, int size) { 8 | return StringUtils.padLeft(input, size); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/ClassC.java: -------------------------------------------------------------------------------- 1 | // 2 | // Source code recreated from a .class file by IntelliJ IDEA 3 | // (powered by FernFlower decompiler) 4 | // 5 | 6 | package com.example; 7 | 8 | class ClassC { 9 | static String append(String input) { 10 | return input + "C"; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include 'skippy-core' 2 | include 'skippy-gradle' 3 | include 'skippy-gradle-android' 4 | include 'skippy-maven' 5 | include 'skippy-junit4' 6 | include 'skippy-junit5' 7 | include 'skippy-extensions:skippy-prediction-modifier-regression-suite' 8 | include 'skippy-extensions:skippy-repository-filesystem' 9 | include 'skippy-extensions:skippy-repository-regression-suite' 10 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/LeftPadderTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Test; 5 | 6 | public class LeftPadderTest { 7 | 8 | @Test 9 | void testPadLeft() { 10 | String input = "hello"; 11 | Assertions.assertEquals(" hello", LeftPadder.padLeft(input, 6)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/StringUtils.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | class StringUtils { 4 | 5 | static String padLeft(String input, int size) { 6 | return input.length() < size ? padLeft(" " + input, size) : input; 7 | } 8 | 9 | static String padRight(String input, int size) { 10 | return input.length() < size ? padRight(input + " ", size) : input; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /skippy-junit4/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-junit4' 8 | description = 'Skippy\'s Predictive Test Selection for JUnit 4' 9 | } 10 | 11 | dependencies { 12 | api 'junit:junit:' + versions.junit4 13 | api project(':skippy-core') 14 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 15 | } 16 | 17 | test { 18 | testLogging { 19 | events "passed", "skipped", "failed" 20 | showStandardStreams true 21 | exceptionFormat 'FULL' 22 | } 23 | } -------------------------------------------------------------------------------- /skippy-junit5/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-junit5' 8 | description = 'Skippy\'s Predictive Test Selection for JUnit 5' 9 | } 10 | 11 | dependencies { 12 | api 'org.junit.jupiter:junit-jupiter-api:' + versions.junit5 13 | api project(':skippy-core') 14 | implementation 'org.junit.jupiter:junit-jupiter-engine:' + versions.junit5 15 | testImplementation 'org.mockito:mockito-core:5.4.0' 16 | } 17 | 18 | test { 19 | testLogging { 20 | events "passed", "skipped", "failed" 21 | showStandardStreams true 22 | exceptionFormat 'FULL' 23 | } 24 | useJUnitPlatform() 25 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/ 9 | .idea/modules.xml 10 | .idea/jarRepositories.xml 11 | .idea/compiler.xml 12 | .idea/libraries/ 13 | *.iws 14 | *.iml 15 | *.ipr 16 | out/ 17 | !**/src/main/**/out/ 18 | !**/src/test/**/out/ 19 | 20 | ### Eclipse ### 21 | .apt_generated 22 | .classpath 23 | .factorypath 24 | .project 25 | .settings 26 | .springBeans 27 | .sts4-cache 28 | bin/ 29 | !**/src/main/**/bin/ 30 | !**/src/test/**/bin/ 31 | 32 | ### NetBeans ### 33 | /nbproject/private/ 34 | /nbbuild/ 35 | /dist/ 36 | /nbdist/ 37 | /.nb-gradle/ 38 | 39 | ### VS Code ### 40 | .vscode/ 41 | 42 | ### Mac OS ### 43 | .DS_Store 44 | 45 | .skippy/ -------------------------------------------------------------------------------- /skippy-extensions/skippy-prediction-modifier-regression-suite/src/main/java/io/skippy/extension/RegressionSuitePredictionModifier.java: -------------------------------------------------------------------------------- 1 | package io.skippy.extension; 2 | 3 | import io.skippy.core.*; 4 | 5 | import java.util.Optional; 6 | 7 | /** 8 | * Custom {@link PredictionModifier} that is internally used by the tests in skippy-regression-suite. 9 | */ 10 | public class RegressionSuitePredictionModifier implements PredictionModifier { 11 | 12 | @Override 13 | public PredictionWithReason passThruOrModify(Class test, PredictionWithReason prediction) { 14 | return new PredictionWithReason(Prediction.ALWAYS_EXECUTE, new Reason(Reason.Category.OVERRIDE_BY_PREDICTION_MODIFIER, Optional.of("RegressionSuitePredictionModifier"))); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/ClassNameAndPrediction.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | record ClassNameAndPrediction(String className, Prediction prediction) {} 20 | -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-filesystem/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-repository-filesystem' 8 | description = 'Sample repository extension that stores all data in the filesystem' 9 | } 10 | 11 | dependencies { 12 | implementation project(':skippy-core') 13 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 14 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 15 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 16 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 17 | } 18 | 19 | test { 20 | testLogging { 21 | events "passed", "skipped", "failed" 22 | showStandardStreams true 23 | exceptionFormat 'FULL' 24 | } 25 | useJUnitPlatform() 26 | } -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-regression-suite/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-repository-regression-suite' 8 | description = 'Repository extension that is used by the tests in skippy-regression-suite' 9 | } 10 | 11 | dependencies { 12 | implementation project(':skippy-core') 13 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 14 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 15 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 16 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 17 | } 18 | 19 | test { 20 | testLogging { 21 | events "passed", "skipped", "failed" 22 | showStandardStreams true 23 | exceptionFormat 'FULL' 24 | } 25 | useJUnitPlatform() 26 | } -------------------------------------------------------------------------------- /skippy-extensions/skippy-prediction-modifier-regression-suite/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-prediction-modifier-regression-suite' 8 | description = 'Prediction modifier that is used by the tests in skippy-regression-suite' 9 | } 10 | 11 | dependencies { 12 | implementation project(':skippy-core') 13 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 14 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 15 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 16 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 17 | } 18 | 19 | test { 20 | testLogging { 21 | events "passed", "skipped", "failed" 22 | showStandardStreams true 23 | exceptionFormat 'FULL' 24 | } 25 | useJUnitPlatform() 26 | } -------------------------------------------------------------------------------- /.github/workflows/gradle-pr.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 6 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle 7 | 8 | name: Java CI with Gradle 9 | 10 | on: 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up JDK 17 25 | uses: actions/setup-java@v3 26 | with: 27 | java-version: '17' 28 | distribution: 'temurin' 29 | - name: Build with Gradle 30 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 31 | with: 32 | arguments: build 33 | -------------------------------------------------------------------------------- /skippy-core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-core' 8 | description = 'Common functionality for Skippy\'s test and build libraries' 9 | } 10 | 11 | dependencies { 12 | implementation "org.ow2.asm:asm-tree:" + versions.asm 13 | implementation 'org.jacoco:org.jacoco.core:' + versions.jacoco 14 | compileOnly 'org.jacoco:org.jacoco.agent:' + versions.jacoco + ':runtime' 15 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 16 | testImplementation "org.junit.jupiter:junit-jupiter-params:" + versions.junit5 17 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 18 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 19 | testImplementation 'org.skyscreamer:jsonassert:1.5.1' 20 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 21 | } 22 | 23 | test { 24 | testLogging { 25 | events "passed", "skipped", "failed" 26 | showStandardStreams true 27 | exceptionFormat 'FULL' 28 | } 29 | useJUnitPlatform() 30 | } -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/DefaultPredictionModifierTest.java: -------------------------------------------------------------------------------- 1 | package io.skippy.core; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.Optional; 6 | 7 | import static org.junit.jupiter.api.Assertions.assertEquals; 8 | 9 | public class DefaultPredictionModifierTest { 10 | 11 | class NormalTestClass { 12 | } 13 | 14 | @AlwaysRun 15 | class AnnotatedTestClass { 16 | } 17 | 18 | @Test 19 | void testEvaluationOfNormalClass() { 20 | var modifier = new DefaultPredictionModifier(); 21 | var actual = modifier.passThruOrModify(NormalTestClass.class, PredictionWithReason.skip(new Reason(Reason.Category.NO_CHANGE, Optional.empty()))); 22 | assertEquals(Prediction.SKIP, actual.prediction()); 23 | } 24 | 25 | @Test 26 | void testEvaluationOfClassAnnotatedWithAlwaysRun() { 27 | var modifier = new DefaultPredictionModifier(); 28 | var actual = modifier.passThruOrModify(AnnotatedTestClass.class, PredictionWithReason.skip(new Reason(Reason.Category.NO_CHANGE, Optional.empty()))); 29 | assertEquals(Prediction.ALWAYS_EXECUTE, actual.prediction()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/com/example/NestedTestsTest.java: -------------------------------------------------------------------------------- 1 | package com.example; 2 | 3 | import org.junit.jupiter.api.Assertions; 4 | import org.junit.jupiter.api.Nested; 5 | import org.junit.jupiter.api.Test; 6 | 7 | public class NestedTestsTest { 8 | 9 | @Test 10 | void testSomething() { 11 | Assertions.assertEquals("helloA", ClassA.append("hello")); 12 | } 13 | 14 | @Nested 15 | class Level2BarTest { 16 | Level2BarTest() { 17 | } 18 | 19 | @Test 20 | void testSomething() { 21 | Assertions.assertEquals("helloD", ClassD.append("hello")); 22 | } 23 | } 24 | 25 | @Nested 26 | class Level2FooTest { 27 | Level2FooTest() { 28 | } 29 | 30 | @Test 31 | void testSomething() { 32 | Assertions.assertEquals("helloB", ClassB.append("hello")); 33 | } 34 | 35 | @Nested 36 | class Level3Test { 37 | Level3Test() { 38 | } 39 | 40 | @Test 41 | void testSomething() { 42 | Assertions.assertEquals("helloC", ClassC.append("hello")); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/Prediction.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | /** 20 | * Skip-or-execute prediction. 21 | * 22 | * @author Florian McKee 23 | */ 24 | public enum Prediction { 25 | 26 | /** 27 | * Execute the test. 28 | */ 29 | EXECUTE, 30 | 31 | /** 32 | * Execute (don't use Skippy to try to predict whether the test needs to run or not). 33 | */ 34 | ALWAYS_EXECUTE, 35 | 36 | /** 37 | * Skip the test. 38 | */ 39 | SKIP 40 | } 41 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/ClassFileCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.List; 20 | 21 | /** 22 | * Collects {@link ClassFile}s in the output directories of the project. 23 | * 24 | * @author Florian McKee 25 | */ 26 | public interface ClassFileCollector { 27 | 28 | /** 29 | * Collects all {@link ClassFile}s in the output directories of the project. 30 | * 31 | * @return all {@link ClassFile}s in the output directories of the project. 32 | */ 33 | List collect(); 34 | 35 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/SkippyConstants.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.nio.file.Path; 20 | 21 | /** 22 | * Comment to make the JavaDoc task happy. 23 | * 24 | * @author Florian McKee 25 | */ 26 | final class SkippyConstants { 27 | 28 | /** 29 | * Directory that contains the Skippy analysis. 30 | */ 31 | static final Path SKIPPY_DIRECTORY = Path.of(".skippy"); 32 | 33 | /** 34 | * Log file for skip-or-execute predictions. 35 | */ 36 | static final Path PREDICTIONS_LOG_FILE = Path.of("predictions.log"); 37 | 38 | /** 39 | * Log file for profiling data. 40 | */ 41 | static final Path PROFILING_LOG_FILE = Path.of("profiling.log"); 42 | 43 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/PredictionWithReason.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | /** 20 | * 2-tuple that contains a {@link Prediction} and the {@link Reason} why the prediction was made. 21 | * 22 | * @param prediction a {@link Prediction} 23 | * @param reason the reason the {@link Prediction} was made 24 | * 25 | * @author Florian McKee 26 | */ 27 | public record PredictionWithReason(Prediction prediction, Reason reason) { 28 | static PredictionWithReason execute(Reason reason) { 29 | return new PredictionWithReason(Prediction.EXECUTE, reason); 30 | } 31 | 32 | static PredictionWithReason skip(Reason reason) { 33 | return new PredictionWithReason(Prediction.SKIP, reason); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /skippy-maven/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'java-library' 3 | id 'io.skippy.ossrh-publish' 4 | } 5 | 6 | ossrhPublish { 7 | title = 'skippy-maven' 8 | description = 'Skippy\'s Test Impact Analysis for Maven' 9 | } 10 | 11 | dependencies { 12 | implementation project(':skippy-core') 13 | implementation 'org.apache.maven:maven-plugin-api:' + versions.maven 14 | implementation 'org.apache.maven:maven-core:' + versions.maven 15 | implementation 'org.apache.maven.plugin-tools:maven-plugin-annotations:' + versions.'maven-plugin-annotations' 16 | implementation 'org.apache.maven:maven-project:' + versions.'maven-project' 17 | testImplementation 'org.junit.jupiter:junit-jupiter-api:' + versions.junit5 18 | testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:' + versions.junit5 19 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 20 | } 21 | 22 | test { 23 | testLogging { 24 | events "passed", "skipped", "failed" 25 | showStandardStreams true 26 | exceptionFormat 'FULL' 27 | } 28 | useJUnitPlatform() 29 | } 30 | 31 | processResources { 32 | def tokens = [ 33 | 'skippy.version': versions.getProperty('skippy') 34 | ] 35 | 36 | // Use the filter method with ReplaceTokens for token replacement 37 | filesMatching('**/*.xml') { 38 | filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: tokens) 39 | } 40 | } -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/SkippyAnalyzeTask.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import org.gradle.api.DefaultTask; 21 | import org.gradle.api.provider.Property; 22 | import org.gradle.api.tasks.Internal; 23 | 24 | import javax.inject.Inject; 25 | 26 | /** 27 | * Informs Skippy that the relevant parts of the build (e.g., compilation and testing) have finished. 28 | * 29 | * @author Florian McKee 30 | */ 31 | abstract class SkippyAnalyzeTask extends DefaultTask { 32 | 33 | @Internal 34 | abstract Property getProjectSettings(); 35 | 36 | @Inject 37 | public SkippyAnalyzeTask() { 38 | setGroup("skippy"); 39 | doLast(task -> getProjectSettings().get().ifBuildSupportsSkippy(SkippyBuildApi::buildFinished)); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /skippy-junit5/src/main/java/io/skippy/junit5/CoverageFileCallbacks.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit5; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import org.junit.jupiter.api.extension.*; 21 | 22 | /** 23 | * Callbacks that trigger the capture of coverage data for a test class. 24 | * 25 | * @author Florian McKee 26 | */ 27 | public final class CoverageFileCallbacks implements BeforeAllCallback, AfterAllCallback { 28 | 29 | private final SkippyTestApi skippyTestApi = SkippyTestApi.INSTANCE; 30 | 31 | @Override 32 | public void beforeAll(ExtensionContext context) { 33 | context.getTestClass().ifPresent(skippyTestApi::beforeAll); 34 | } 35 | 36 | @Override 37 | public void afterAll(ExtensionContext context) { 38 | context.getTestClass().ifPresent(skippyTestApi::afterAll); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/AndroidDestinationDirectoryCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import org.gradle.api.Project; 20 | import org.gradle.api.tasks.compile.JavaCompile; 21 | 22 | import java.io.File; 23 | import java.util.stream.Stream; 24 | 25 | /** 26 | * Returns the destination directories of the {@link JavaCompile} task. 27 | * 28 | * @author Eugeniu Tufar 29 | * @author Florian McKee 30 | */ 31 | final class AndroidDestinationDirectoryCollector { 32 | private AndroidDestinationDirectoryCollector() {} 33 | 34 | static Stream collect(Project project) { 35 | return project.getTasks() 36 | .withType(JavaCompile.class) 37 | .stream() 38 | .map(task -> task.getDestinationDirectory().getAsFile().get()); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/SkippyAnalyzeTask.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import org.gradle.api.DefaultTask; 21 | import org.gradle.api.provider.Property; 22 | import org.gradle.api.tasks.Internal; 23 | 24 | import javax.inject.Inject; 25 | 26 | /** 27 | * Informs Skippy that the relevant parts of the build (e.g., compilation and testing) have finished. 28 | * 29 | * @author Florian McKee 30 | */ 31 | abstract class SkippyAnalyzeTask extends DefaultTask { 32 | 33 | @Internal 34 | abstract Property getProjectSettings(); 35 | 36 | @Inject 37 | public SkippyAnalyzeTask() { 38 | setGroup("skippy"); 39 | doLast(task -> getProjectSettings().get().ifBuildSupportsSkippy(SkippyBuildApi::buildFinished)); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time 6 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle 7 | 8 | name: Java CI with Gradle 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up JDK 17 25 | uses: actions/setup-java@v3 26 | with: 27 | java-version: '17' 28 | distribution: 'temurin' 29 | - name: Build with Gradle 30 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 31 | with: 32 | arguments: build 33 | 34 | - name: Deploy snapshot to Sonatype OSS Repository Hosting 35 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 36 | with: 37 | arguments: publishSnapshot 38 | env: 39 | OSSRH_TOKEN: ${{ secrets.OSSRH_TOKEN }} 40 | OSSRH_TOKEN_SECRET: ${{ secrets.OSSRH_TOKEN_SECRET }} 41 | SKIPPY_PRIVATE_KEY: ${{ secrets.SKIPPY_PRIVATE_KEY }} 42 | SKIPPY_PRIVATE_KEY_SECRET: ${{ secrets.SKIPPY_PRIVATE_KEY_SECRET }} 43 | -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/SkippyCleanTask.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import org.gradle.api.DefaultTask; 21 | import org.gradle.api.provider.Property; 22 | import org.gradle.api.tasks.Internal; 23 | 24 | import javax.inject.Inject; 25 | 26 | /** 27 | * Resets the skippy folder: After completion, only an up-to-date config.json will remain. 28 | *

29 | * Invocation: {@code ./gradlew skippyClean} 30 | * 31 | * @author Florian McKee 32 | */ 33 | abstract class SkippyCleanTask extends DefaultTask { 34 | 35 | @Internal 36 | abstract Property getProjectSettings(); 37 | 38 | @Inject 39 | public SkippyCleanTask() { 40 | setGroup("skippy"); 41 | doLast(task -> getProjectSettings().get().ifBuildSupportsSkippy(SkippyBuildApi::resetSkippyFolder)); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /skippy-junit5/src/main/java/io/skippy/junit5/TestResultExtension.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit5; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import io.skippy.core.TestTag; 21 | import org.junit.jupiter.api.extension.AfterAllCallback; 22 | import org.junit.jupiter.api.extension.BeforeAllCallback; 23 | import org.junit.jupiter.api.extension.ExtensionContext; 24 | import org.junit.jupiter.api.extension.TestWatcher; 25 | 26 | /** 27 | * Callbacks that trigger the capture of coverage data for a test class. 28 | * 29 | * @author Florian McKee 30 | */ 31 | public final class TestResultExtension implements TestWatcher { 32 | 33 | private final SkippyTestApi skippyTestApi = SkippyTestApi.INSTANCE; 34 | 35 | @Override 36 | public void testFailed(ExtensionContext context, Throwable cause) { 37 | skippyTestApi.tagTest(context.getTestClass().get(), TestTag.FAILED); 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/SkippyCleanTask.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import org.gradle.api.DefaultTask; 21 | import org.gradle.api.provider.Property; 22 | import org.gradle.api.tasks.Internal; 23 | 24 | import javax.inject.Inject; 25 | 26 | /** 27 | * Resets the skippy folder: After completion, only an up-to-date config.json will remain. 28 | *

29 | * Invocation: {@code ./gradlew skippyClean} 30 | * 31 | * @author Florian McKee 32 | */ 33 | abstract class SkippyCleanTask extends DefaultTask { 34 | 35 | @Internal 36 | abstract Property getProjectSettings(); 37 | 38 | @Inject 39 | public SkippyCleanTask() { 40 | setGroup("skippy"); 41 | doLast(task -> getProjectSettings().get().ifBuildSupportsSkippy(SkippyBuildApi::resetSkippyFolder)); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /skippy-junit5/src/main/java/io/skippy/junit5/PredictWithSkippy.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit5; 18 | 19 | import org.junit.jupiter.api.extension.ExtendWith; 20 | 21 | import java.lang.annotation.ElementType; 22 | import java.lang.annotation.Retention; 23 | import java.lang.annotation.RetentionPolicy; 24 | import java.lang.annotation.Target; 25 | 26 | /** 27 | * Enables predictive test selection for a JUnit 5 test : 28 | *
29 | *
30 |  * {@literal @}PredictWithSkippy
31 |  *  public class FooTest {
32 |  *
33 |  *    {@literal @}Test
34 |  *     void testFoo() {
35 |  *         ...
36 |  *     }
37 |  *
38 |  * }
39 |  * 
40 | * 41 | * @author Florian McKee 42 | */ 43 | @Target(ElementType.TYPE) 44 | @Retention(RetentionPolicy.RUNTIME) 45 | @ExtendWith(SkipOrExecuteCondition.class) 46 | @ExtendWith(CoverageFileCallbacks.class) 47 | @ExtendWith(TestResultExtension.class) 48 | public @interface PredictWithSkippy { 49 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/AlwaysRun.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.lang.annotation.ElementType; 20 | import java.lang.annotation.Retention; 21 | import java.lang.annotation.RetentionPolicy; 22 | import java.lang.annotation.Target; 23 | 24 | /** 25 | * Disables Skippy's predictive test selection: 26 | *

27 | *
28 |  * {@literal @}AlwaysRun
29 |  *  public class FooTest {
30 |  *
31 |  *    {@literal @}Test
32 |  *     void testFoo() {
33 |  *         ...
34 |  *     }
35 |  *
36 |  *    {@literal @}Test
37 |  *     void testBar() {
38 |  *         ...
39 |  *     }
40 |  *
41 |  * }
42 |  * 
43 | * 44 | * This is particularly useful if you use JUnit 5's automatic extension registration mechanism to enable Skippy for 45 | * all tests. 46 | * 47 | * @author Florian McKee 48 | */ 49 | @Target({ElementType.TYPE}) 50 | @Retention(RetentionPolicy.RUNTIME) 51 | public @interface AlwaysRun { 52 | } -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/HashUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.nio.charset.StandardCharsets; 22 | import java.nio.file.Path; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | 26 | public class HashUtilTest { 27 | 28 | @Test 29 | void testHashWith32Digits() { 30 | assertEquals("ACBD18DB4CC2F85CEDEF654FCCC4A4D8", HashUtil.hashWith32Digits("foo".getBytes(StandardCharsets.UTF_8))); 31 | assertEquals("37B51D194A7513E45B56F6524F2D51F2", HashUtil.hashWith32Digits("bar".getBytes(StandardCharsets.UTF_8))); 32 | assertEquals("D41D8CD98F00B204E9800998ECF8427E", HashUtil.hashWith32Digits(new byte[] {})); 33 | } 34 | 35 | @Test 36 | void testHashOfClassFile() { 37 | var classFile = Path.of("build/classes/java/test").resolve("com/example/StringUtils.class"); 38 | assertEquals("BF9A6640", HashUtil.debugAgnosticHash(classFile)); 39 | } 40 | 41 | 42 | } 43 | -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/KotlinDestinationDirectoryCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import org.gradle.api.Project; 20 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompileTool; 21 | 22 | import java.io.File; 23 | import java.util.stream.Stream; 24 | 25 | /** 26 | * Returns the destination directories of the {@link KotlinCompileTool} task. 27 | * 28 | * @author Eugeniu Tufar 29 | * @author Florian McKee 30 | */ 31 | final class KotlinDestinationDirectoryCollector { 32 | private KotlinDestinationDirectoryCollector() {} 33 | 34 | static Stream collect(Project project) { 35 | return project.getTasks().stream() 36 | .filter(task -> task.getName().startsWith("compile") && task.getName().endsWith("Kotlin")) 37 | .filter(task -> task instanceof KotlinCompileTool) 38 | .map(task -> (KotlinCompileTool) task) 39 | .map(kotlinCompileTool -> kotlinCompileTool.getDestinationDirectory().get().getAsFile()); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisParsePerformanceTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.io.IOException; 22 | import java.net.URISyntaxException; 23 | import java.nio.charset.StandardCharsets; 24 | import java.nio.file.Files; 25 | import java.nio.file.Paths; 26 | 27 | import static org.junit.jupiter.api.Assertions.assertEquals; 28 | 29 | public class TestImpactAnalysisParsePerformanceTest { 30 | 31 | @Test 32 | void testParse() throws URISyntaxException, IOException { 33 | var jsonFile = Paths.get(getClass().getResource("test-impact-analysis.json").toURI()); 34 | var testImpactAnalysis = TestImpactAnalysis.parse(Files.readString(jsonFile, StandardCharsets.UTF_8)); 35 | Profiler.printResults(); 36 | assertEquals("55AB349797F1169672E84163857DCB06", testImpactAnalysis.getId()); 37 | assertEquals(2510, testImpactAnalysis.getClassFileContainer().getClassFiles().size()); 38 | assertEquals(400, testImpactAnalysis.getAnalyzedTests().size()); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/TestTag.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | 22 | /** 23 | * Tags that can be associated with a test. 24 | * 25 | * @author Florian McKee 26 | */ 27 | public enum TestTag { 28 | 29 | /** 30 | * The test was successful. 31 | */ 32 | PASSED, 33 | 34 | /** 35 | * The test failed. 36 | */ 37 | FAILED, 38 | 39 | /** 40 | * The test must always execute (don't make {@link Prediction#SKIP} predictions). 41 | */ 42 | ALWAYS_EXECUTE; 43 | 44 | static List parseList(Tokenizer tokenizer) { 45 | return Profiler.profile("TestTag#parseList", () -> { 46 | var testTags = new ArrayList(); 47 | tokenizer.skip('['); 48 | while (!tokenizer.peek(']')) { 49 | tokenizer.skipIfNext(','); 50 | testTags.add(TestTag.valueOf(tokenizer.next())); 51 | } 52 | tokenizer.skip(']'); 53 | return testTags; 54 | }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /skippy-junit4/src/main/java/io/skippy/junit4/Skippy.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit4; 18 | 19 | import org.junit.rules.ExternalResource; 20 | import org.junit.rules.RuleChain; 21 | import org.junit.rules.TestRule; 22 | 23 | /** 24 | * {@link TestRule} that enables Skippy's predictive test selection for a JUnit 4 test: 25 | * 26 | *
27 |  * public class FooTest {
28 |  *
29 |  *    {@literal @}Rule
30 |  *     public TestRule skippyRule = Skippy.predictWithSkippy();
31 |  *
32 |  *    {@literal @}Test
33 |  *     public void testFoo() {
34 |  *         ...
35 |  *     }
36 |  *
37 |  * }
38 |  * 
39 | */ 40 | public class Skippy extends ExternalResource { 41 | 42 | /** 43 | * Creates a {@link TestRule} that enables predictive test selection for a JUnit 4 test. 44 | * 45 | * @return a {@link TestRule} that enables predictive test selection for a JUnit 4 test 46 | */ 47 | public static TestRule predictWithSkippy() { 48 | return RuleChain 49 | .outerRule(new SkipOrExecuteRule()) 50 | .around(new CoverageFileRule()); 51 | } 52 | 53 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/PredictionModifier.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | /** 20 | * Extension point that allows projects to customize predictions made by Skippy. 21 | * 22 | * Example use cases: 23 | *
    24 | *
  • disable Skippy for all tests that carry a custom annotation,
  • 25 | *
  • disable Skippy for all tests within a package,
  • 26 | *
  • etc.
  • 27 | *
28 | * 29 | * Custom implementations must have a public no-args constructor. 30 | * They can be registered using Skippy's build plugins. 31 | * 32 | *

33 | * 34 | * Gradle example: 35 | *
36 |  * skippy {
37 |  *     ...
38 |  *     predictionModifier = 'com.example.CustomPredictionModifier'
39 |  * }
40 |  * 
41 | * 42 | * @author Florian McKee 43 | */ 44 | public interface PredictionModifier { 45 | 46 | /** 47 | * Returns a modified or unmodified prediction made by Skippy. 48 | * 49 | * @param test a class object representing a test 50 | * @param prediction the prediction made by Skippy. 51 | * @return the modified or unmodified prediction 52 | */ 53 | PredictionWithReason passThruOrModify(Class test, PredictionWithReason prediction); 54 | 55 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/TestRecording.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.nio.file.Path; 20 | import java.util.List; 21 | 22 | /** 23 | * Data that is being recorded during the execution of a test class: 24 | *
    25 | *
  • the class name (e.g., com.example.FooTest),
  • 26 | *
  • the output folder the class is located in (e.g., build/classes/java/test),
  • 27 | *
  • a list of {@link TestTag}s,
  • 28 | *
  • a list of {@link ClassNameAndJaCoCoId} that represents the classes covered by the test and
  • 29 | *
  • the test's JaCoCo execution data
  • 30 | *
31 | * 32 | * @param className the class name of a test 33 | * @param outputFolder the output folder the test's class file is located in 34 | * @param tags a list of {@link TestTag}s 35 | * @param coveredClasses a list of {@link ClassNameAndJaCoCoId}s 36 | * @param jacocoExecutionData the test's JaCoCo execution data 37 | * 38 | * @author Florian McKee 39 | */ 40 | record TestRecording(String className, Path outputFolder, List tags, List coveredClasses, byte[] jacocoExecutionData) { 41 | public String getPath() { 42 | return "%s/%s".formatted(outputFolder, className); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/ClassNameAndJaCoCoId.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.jacoco.core.internal.data.CRC64; 20 | 21 | import java.io.IOException; 22 | 23 | /** 24 | * Coverage data extracted from a JaCoCo execution data file. 25 | * 26 | * @author Florian McKee 27 | * 28 | * @param className the name of the covered class 29 | * @param jaCoCoId the JaCoCo class id of the covered class 30 | */ 31 | record ClassNameAndJaCoCoId(String className, long jaCoCoId) implements Comparable { 32 | 33 | static ClassNameAndJaCoCoId from(Class clazz) { 34 | String resourcePath = clazz.getName().replace('.', '/') + ".class"; 35 | try (var classFileStream = clazz.getClassLoader().getResourceAsStream(resourcePath)) { 36 | return new ClassNameAndJaCoCoId(clazz.getName(), CRC64.classId(classFileStream.readAllBytes())); 37 | } catch (IOException e) { 38 | throw new RuntimeException("Unable to create new instance from %s: %s".formatted(clazz, e), e); 39 | } 40 | } 41 | 42 | @Override 43 | public int compareTo(ClassNameAndJaCoCoId other) { 44 | if (false == this.className.equals(other.className)) { 45 | return className.compareTo(other.className()); 46 | } 47 | return Long.compare(this.jaCoCoId, other.jaCoCoId); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /skippy-junit5/src/main/java/io/skippy/junit5/SkipOrExecuteCondition.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit5; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import org.junit.jupiter.api.extension.ConditionEvaluationResult; 21 | import org.junit.jupiter.api.extension.ExecutionCondition; 22 | import org.junit.jupiter.api.extension.ExtensionContext; 23 | 24 | /** 25 | * {@link ExecutionCondition} that makes skip-or-execute predictions for tests. 26 | * 27 | * @author Florian McKee 28 | */ 29 | public final class SkipOrExecuteCondition implements ExecutionCondition { 30 | 31 | private final SkippyTestApi skippyTestApi; 32 | 33 | /** 34 | * Comment to make the JavaDoc task happy. 35 | */ 36 | public SkipOrExecuteCondition() { 37 | this(SkippyTestApi.INSTANCE); 38 | } 39 | 40 | SkipOrExecuteCondition(final SkippyTestApi skippyTestApi) { 41 | this.skippyTestApi = skippyTestApi; 42 | } 43 | 44 | @Override 45 | public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { 46 | if (context.getTestClass().isEmpty()) { 47 | return ConditionEvaluationResult.enabled(""); 48 | } 49 | if (skippyTestApi.testNeedsToBeExecuted(context.getTestClass().get())) { 50 | return ConditionEvaluationResult.enabled(""); 51 | } 52 | return ConditionEvaluationResult.disabled(""); 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /skippy-junit4/src/main/java/io/skippy/junit4/SkipOrExecuteRule.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit4; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import org.junit.AssumptionViolatedException; 21 | import org.junit.rules.TestRule; 22 | import org.junit.runner.Description; 23 | import org.junit.runners.model.Statement; 24 | 25 | /** 26 | * {@link TestRule} that makes skip-or-execute predictions for test. 27 | * 28 | * @author Florian McKee 29 | */ 30 | class SkipOrExecuteRule implements TestRule { 31 | 32 | private final SkippyTestApi skippyTestApi; 33 | 34 | public SkipOrExecuteRule() { 35 | this(SkippyTestApi.INSTANCE); 36 | } 37 | 38 | SkipOrExecuteRule(SkippyTestApi skippyTestApi) { 39 | this.skippyTestApi = skippyTestApi; 40 | } 41 | 42 | public Statement apply(Statement base, Description description) { 43 | return new Statement() { 44 | @Override 45 | public void evaluate() throws Throwable { 46 | if (executeTest(description.getTestClass())) { 47 | base.evaluate(); 48 | } else { 49 | throw new AssumptionViolatedException("Test skipped by Skippy."); 50 | } 51 | } 52 | 53 | private boolean executeTest(Class testClass) { 54 | return skippyTestApi.testNeedsToBeExecuted(testClass); 55 | } 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /skippy-junit4/src/test/java/io/skippy/junit4/SkipOrExecuteRuleTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit4; 18 | import io.skippy.core.SkippyTestApi; 19 | import org.junit.Test; 20 | import org.junit.runner.Description; 21 | import org.junit.runners.model.Statement; 22 | 23 | import static org.mockito.Mockito.*; 24 | 25 | /** 26 | * Tests for {@link SkipOrExecuteRule} 27 | */ 28 | public class SkipOrExecuteRuleTest { 29 | 30 | SkippyTestApi skippyTestApi = mock(SkippyTestApi.class); 31 | Statement base = mock(Statement.class); 32 | Description description = mock(Description.class); 33 | 34 | @Test 35 | public void testExecutionofTest() throws Throwable { 36 | doReturn(SkipOrExecuteRuleTest.class).when(description).getTestClass(); 37 | var rule = new SkipOrExecuteRule(skippyTestApi); 38 | when(skippyTestApi.testNeedsToBeExecuted(SkipOrExecuteRuleTest.class)).thenReturn(true); 39 | rule.apply(base, description).evaluate(); 40 | verify(base).evaluate(); 41 | } 42 | 43 | @Test 44 | public void testSkippingOfTest() throws Throwable { 45 | doReturn(SkipOrExecuteRuleTest.class).when(description).getTestClass(); 46 | var rule = new SkipOrExecuteRule(skippyTestApi); 47 | when(skippyTestApi.testNeedsToBeExecuted(SkipOrExecuteRuleTest.class)).thenReturn(false); 48 | rule.apply(base, description).evaluate(); 49 | verify(base, times(0)).evaluate(); 50 | } 51 | 52 | } -------------------------------------------------------------------------------- /skippy-gradle/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | maven { 4 | url = uri("https://plugins.gradle.org/m2/") 5 | } 6 | } 7 | dependencies { 8 | classpath("com.gradle.publish:plugin-publish-plugin:1.2.1") 9 | } 10 | } 11 | 12 | plugins { 13 | id 'java-gradle-plugin' 14 | } 15 | 16 | def publishToGradlePluginPortal = project.hasProperty("publishToGradlePluginPortal") 17 | && Boolean.valueOf(project.property("publishToGradlePluginPortal")) 18 | 19 | if ( publishToGradlePluginPortal) { 20 | 21 | // Gradle Plugin Portal 22 | 23 | apply plugin: 'com.gradle.plugin-publish' 24 | 25 | } else { 26 | 27 | // SonaType OSSRH 28 | 29 | apply plugin: 'io.skippy.ossrh-publish' 30 | 31 | ossrhPublish { 32 | title = 'skippy-gradle' 33 | description = 'Skippy\'s Test Impact Analysis for Gradle' 34 | } 35 | } 36 | 37 | repositories { 38 | mavenCentral() 39 | } 40 | 41 | dependencies { 42 | implementation project(':skippy-core') 43 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 44 | testImplementation "org.junit.jupiter:junit-jupiter-params:" + versions.junit5 45 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 46 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 47 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 48 | testImplementation gradleTestKit() 49 | } 50 | 51 | test { 52 | testLogging { 53 | events "passed", "skipped", "failed" 54 | showStandardStreams true 55 | exceptionFormat 'FULL' 56 | } 57 | useJUnitPlatform() 58 | } 59 | 60 | gradlePlugin { 61 | 62 | website = 'https://github.com/skippy-io/skippy' 63 | vcsUrl = 'https://github.com/skippy-io/skippy' 64 | 65 | plugins { 66 | skippyPlugin { 67 | id = 'io.skippy' 68 | displayName = 'skippy-gradle' 69 | description = 'Skippy\'s Test Impact Analysis for Gradle' 70 | tags.set(['skippy', 'test-impact-analysis']) 71 | implementationClass = 'io.skippy.gradle.SkippyPlugin' 72 | } 73 | } 74 | automatedPublishing = publishToGradlePluginPortal 75 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/SkippyFolder.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.io.IOException; 20 | import java.io.UncheckedIOException; 21 | import java.nio.file.Path; 22 | 23 | import static io.skippy.core.SkippyConstants.SKIPPY_DIRECTORY; 24 | import static java.nio.file.Files.createDirectories; 25 | import static java.nio.file.Files.exists; 26 | 27 | /** 28 | * A couple of static methods for retrieval and creation of the skippy folder. 29 | * 30 | * @author Florian McKee 31 | */ 32 | final class SkippyFolder { 33 | 34 | /** 35 | * Returns the Skippy folder. This method will create the folder if it doesn't exist. 36 | * 37 | * @return the Skippy folder 38 | */ 39 | static Path get() { 40 | return get(Path.of(".")); 41 | } 42 | 43 | /** 44 | * Returns the Skippy folder in the given {@code projectFolder}. This method will create the folder if it doesn't 45 | * exist. 46 | * 47 | * @param projectFolder the project's root folder 48 | * @return the Skippy folder in the given {@code projectFolder} 49 | */ 50 | static Path get(Path projectFolder) { 51 | try { 52 | var skippyFolder = projectFolder.resolve(SKIPPY_DIRECTORY); 53 | if (false == exists(skippyFolder)) { 54 | createDirectories(skippyFolder); 55 | } 56 | return skippyFolder; 57 | } catch (IOException e) { 58 | throw new UncheckedIOException("Could not create Skippy folder: %s".formatted(e), e); 59 | } 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-regression-suite/src/main/java/io/skippy/extension/RegressionSuiteRepositoryExtension.java: -------------------------------------------------------------------------------- 1 | package io.skippy.extension; 2 | 3 | import io.skippy.core.DefaultRepositoryExtension; 4 | import io.skippy.core.SkippyRepositoryExtension; 5 | import io.skippy.core.TestImpactAnalysis; 6 | 7 | import java.io.IOException; 8 | import java.nio.charset.StandardCharsets; 9 | import java.nio.file.Path; 10 | import java.util.Optional; 11 | 12 | import static java.nio.file.Files.createDirectories; 13 | import static java.nio.file.Files.writeString; 14 | 15 | /** 16 | * Custom {@link SkippyRepositoryExtension} that is internally used by the tests in skippy-regression-suite. 17 | */ 18 | public class RegressionSuiteRepositoryExtension implements SkippyRepositoryExtension { 19 | 20 | private final SkippyRepositoryExtension defaultExtension; 21 | 22 | /** 23 | * Constructor that will be invoked via reflection. 24 | * 25 | * @param projectDir the project directory (e.g., ~/repo) 26 | * @throws IOException an {@link IOException} 27 | */ 28 | public RegressionSuiteRepositoryExtension(Path projectDir) throws IOException { 29 | defaultExtension = new DefaultRepositoryExtension(projectDir); 30 | // write a marker that will be checked by the regression tests 31 | createDirectories(projectDir.resolve(".skippy")); 32 | writeString(projectDir.resolve(".skippy").resolve("REPOSITORY"), getClass().getName(), StandardCharsets.UTF_8); 33 | } 34 | 35 | @Override 36 | public Optional findTestImpactAnalysis(String id) { 37 | return defaultExtension.findTestImpactAnalysis(id); 38 | } 39 | 40 | @Override 41 | public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { 42 | defaultExtension.saveTestImpactAnalysis(testImpactAnalysis); 43 | } 44 | 45 | @Override 46 | public Optional findJacocoExecutionData(String testExecutionId) { 47 | return defaultExtension.findJacocoExecutionData(testExecutionId); 48 | } 49 | 50 | @Override 51 | public void saveJacocoExecutionData(String testExecutionId, byte[] jacocoExecutionData) { 52 | defaultExtension.saveJacocoExecutionData(testExecutionId, jacocoExecutionData); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/DefaultPredictionModifier.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.Optional; 20 | 21 | /** 22 | * {@link PredictionModifier} that defaults to {@link Prediction#EXECUTE} if a test (or one of its superclasses or 23 | * interfaces it implements is annotated with @{@link AlwaysRun}. 24 | * 25 | * @author Florian McKee 26 | */ 27 | final class DefaultPredictionModifier implements PredictionModifier { 28 | 29 | public DefaultPredictionModifier() { 30 | } 31 | 32 | @Override 33 | public PredictionWithReason passThruOrModify(Class test, PredictionWithReason prediction) { 34 | if (isAnnotatedWithAlwaysRun(test)) { 35 | return new PredictionWithReason( 36 | Prediction.ALWAYS_EXECUTE, 37 | new Reason( 38 | Reason.Category.OVERRIDE_BY_PREDICTION_MODIFIER, 39 | Optional.of("Class, superclass or implementing interface annotated with @%s".formatted(AlwaysRun.class.getSimpleName())) 40 | ) 41 | ); 42 | } 43 | return prediction; 44 | } 45 | 46 | private static boolean isAnnotatedWithAlwaysRun(Class clazz) { 47 | if (clazz.isAnnotationPresent(AlwaysRun.class)) { 48 | return true; 49 | } 50 | for (var interfce : clazz.getInterfaces()) { 51 | if (isAnnotatedWithAlwaysRun(interfce)) { 52 | return true; 53 | } 54 | } 55 | if (clazz.getSuperclass() != null) { 56 | return isAnnotatedWithAlwaysRun(clazz.getSuperclass()); 57 | } 58 | return false; 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /skippy-gradle-android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | maven { 4 | url = uri("https://plugins.gradle.org/m2/") 5 | } 6 | } 7 | dependencies { 8 | classpath("com.gradle.publish:plugin-publish-plugin:1.2.1") 9 | } 10 | } 11 | 12 | plugins { 13 | id 'java-gradle-plugin' 14 | } 15 | 16 | def publishToGradlePluginPortal = project.hasProperty("publishToGradlePluginPortal") 17 | && Boolean.valueOf(project.property("publishToGradlePluginPortal")) 18 | 19 | if ( publishToGradlePluginPortal) { 20 | 21 | // Gradle Plugin Portal 22 | 23 | apply plugin: 'com.gradle.plugin-publish' 24 | 25 | } else { 26 | 27 | // SonaType OSSRH 28 | 29 | apply plugin: 'io.skippy.ossrh-publish' 30 | 31 | ossrhPublish { 32 | title = 'skippy-gradle-android' 33 | description = 'Skippy\'s Test Impact Analysis for Gradle & Android' 34 | } 35 | } 36 | 37 | repositories { 38 | google() 39 | mavenCentral() 40 | } 41 | 42 | dependencies { 43 | implementation project(':skippy-core') 44 | compileOnly 'org.jetbrains.kotlin:kotlin-gradle-plugin:' + versions.'kotlin-gradle-plugin' 45 | testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 46 | testImplementation "org.junit.jupiter:junit-jupiter-params:" + versions.junit5 47 | testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 48 | testImplementation 'org.assertj:assertj-core:' + versions.assertj 49 | testImplementation 'org.mockito:mockito-core:' + versions.mockito 50 | testImplementation gradleTestKit() 51 | } 52 | 53 | test { 54 | testLogging { 55 | events "passed", "skipped", "failed" 56 | showStandardStreams true 57 | exceptionFormat 'FULL' 58 | } 59 | useJUnitPlatform() 60 | } 61 | 62 | gradlePlugin { 63 | 64 | website = 'https://www.skippy.io' 65 | vcsUrl = 'https://github.com/skippy-io/skippy' 66 | 67 | plugins { 68 | skippyPlugin { 69 | id = 'io.skippy.android' 70 | displayName = 'skippy-gradle-android' 71 | description = 'Skippy\'s Test Impact Analysis for Gradle & Android' 72 | tags.set(['skippy', 'test-impact-analysis']) 73 | implementationClass = 'io.skippy.gradle.android.SkippyPlugin' 74 | } 75 | } 76 | automatedPublishing = publishToGradlePluginPortal 77 | } -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/SkippyPlugin.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.Profiler; 20 | import io.skippy.core.TestTag; 21 | import org.gradle.api.Project; 22 | import org.gradle.api.tasks.testing.Test; 23 | import org.gradle.api.tasks.testing.TestDescriptor; 24 | import org.gradle.api.tasks.testing.TestListener; 25 | import org.gradle.api.tasks.testing.TestResult; 26 | import org.gradle.testing.jacoco.plugins.JacocoPlugin; 27 | 28 | /** 29 | * The Skippy plugin adds the 30 | *
    31 | *
  • {@link SkippyAnalyzeTask} and
  • 32 | *
  • {@link SkippyCleanTask}
  • 33 | *
34 | * tasks to the project. 35 | * 36 | *

37 | * 38 | * @author Florian McKee 39 | */ 40 | final class SkippyPlugin implements org.gradle.api.Plugin { 41 | 42 | @Override 43 | public void apply(Project project) { 44 | Profiler.clear(); 45 | 46 | project.getPlugins().apply(JacocoPlugin.class); 47 | project.getExtensions().create("skippy", SkippyPluginExtension.class); 48 | project.getTasks().register("skippyClean", SkippyCleanTask.class); 49 | project.getTasks().register("skippyAnalyze", SkippyAnalyzeTask.class); 50 | 51 | project.afterEvaluate(action -> { 52 | 53 | var projectSettings = ProjectSettings.from(action); 54 | 55 | project.getTasks().withType(SkippyCleanTask.class).forEach( task -> task.getProjectSettings().set(projectSettings)); 56 | project.getTasks().withType(SkippyAnalyzeTask.class).forEach( task -> task.getProjectSettings().set(projectSettings)); 57 | 58 | action.getTasks().withType(Test.class, testTask -> testTask.finalizedBy("skippyAnalyze")); 59 | projectSettings.ifBuildSupportsSkippy(skippyBuildApi -> skippyBuildApi.buildStarted()); 60 | }); 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.SkippyConfiguration; 20 | import org.gradle.api.provider.Property; 21 | 22 | import java.util.Optional; 23 | 24 | /** 25 | * Extension that allows configuration of Skippy in Gradle's build file: 26 | *
27 |  * skippy {
28 |  *     executionData = true
29 |  *     ...
30 |  * }
31 |  * 
32 | * 33 | * @author Florian McKee 34 | */ 35 | public interface SkippyPluginExtension { 36 | 37 | /** 38 | * Returns the property to enable / disable coverage generation for skipped tests. 39 | * 40 | * @return the property to enable / disable coverage generation for skipped tests 41 | */ 42 | Property getCoverageForSkippedTests(); 43 | 44 | /** 45 | * Returns the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension}. 46 | * 47 | * @return the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension} 48 | */ 49 | Property getRepository(); 50 | 51 | /** 52 | * Returns the property to register a custom {@link io.skippy.core.PredictionModifier}. 53 | * 54 | * @return the property to register a custom {@link io.skippy.core.PredictionModifier} 55 | */ 56 | Property getPredictionModifier(); 57 | 58 | /** 59 | * Converts the extension data into a {@link SkippyConfiguration} 60 | * 61 | * @return a {@link SkippyConfiguration} derived from the extension data 62 | */ 63 | default SkippyConfiguration toSkippyConfiguration() { 64 | return new SkippyConfiguration( 65 | getCoverageForSkippedTests().getOrElse(false), 66 | Optional.ofNullable(getRepository().getOrNull()), 67 | Optional.ofNullable(getPredictionModifier().getOrNull()) 68 | ); 69 | } 70 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/SkippyPlugin.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import io.skippy.core.Profiler; 20 | import io.skippy.core.TestTag; 21 | import org.gradle.api.Project; 22 | import org.gradle.api.tasks.testing.Test; 23 | import org.gradle.api.tasks.testing.TestDescriptor; 24 | import org.gradle.api.tasks.testing.TestListener; 25 | import org.gradle.api.tasks.testing.TestResult; 26 | import org.gradle.testing.jacoco.plugins.JacocoPlugin; 27 | 28 | /** 29 | * The Skippy Android plugin adds the 30 | *
    31 | *
  • {@link SkippyAnalyzeTask} and
  • 32 | *
  • {@link SkippyCleanTask}
  • 33 | *
34 | * tasks to the project. 35 | * 36 | *

37 | * 38 | * @author Florian McKee 39 | */ 40 | final class SkippyPlugin implements org.gradle.api.Plugin { 41 | 42 | @Override 43 | public void apply(Project project) { 44 | Profiler.clear(); 45 | 46 | project.getPlugins().apply(JacocoPlugin.class); 47 | project.getExtensions().create("skippy", SkippyPluginExtension.class); 48 | project.getTasks().register("skippyClean", SkippyCleanTask.class); 49 | project.getTasks().register("skippyAnalyze", SkippyAnalyzeTask.class); 50 | 51 | project.afterEvaluate(action -> { 52 | 53 | var projectSettings = ProjectSettings.from(action); 54 | 55 | project.getTasks().withType(SkippyCleanTask.class).forEach( task -> task.getProjectSettings().set(projectSettings)); 56 | project.getTasks().withType(SkippyAnalyzeTask.class).forEach( task -> task.getProjectSettings().set(projectSettings)); 57 | 58 | action.getTasks().withType(Test.class, testTask -> testTask.finalizedBy("skippyAnalyze")); 59 | 60 | projectSettings.ifBuildSupportsSkippy(skippyBuildApi -> skippyBuildApi.buildStarted()); 61 | }); 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/SkippyPluginExtension.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import io.skippy.core.SkippyConfiguration; 20 | import org.gradle.api.provider.Property; 21 | 22 | import java.util.Optional; 23 | 24 | /** 25 | * Extension that allows configuration of Skippy in Gradle's build file: 26 | *
27 |  * skippy {
28 |  *     executionData = true
29 |  *     ...
30 |  * }
31 |  * 
32 | * 33 | * @author Florian McKee 34 | */ 35 | public interface SkippyPluginExtension { 36 | 37 | /** 38 | * Returns the property to enable / disable coverage generation for skipped tests. 39 | * 40 | * @return the property to enable / disable coverage generation for skipped tests 41 | */ 42 | Property getCoverageForSkippedTests(); 43 | 44 | /** 45 | * Returns the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension}. 46 | * 47 | * @return the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension} 48 | */ 49 | Property getRepository(); 50 | 51 | /** 52 | * Returns the property to register a custom {@link io.skippy.core.PredictionModifier}. 53 | * 54 | * @return the property to register a custom {@link io.skippy.core.PredictionModifier} 55 | */ 56 | Property getPredictionModifier(); 57 | 58 | /** 59 | * Converts the extension data into a {@link SkippyConfiguration} 60 | * 61 | * @return a {@link SkippyConfiguration} derived from the extension data 62 | */ 63 | default SkippyConfiguration toSkippyConfiguration() { 64 | return new SkippyConfiguration( 65 | getCoverageForSkippedTests().getOrElse(false), 66 | Optional.ofNullable(getRepository().getOrNull()), 67 | Optional.ofNullable(getPredictionModifier().getOrNull()) 68 | ); 69 | } 70 | } -------------------------------------------------------------------------------- /skippy-maven/src/main/resources/META-INF/maven/io.skippy/skippy-maven/plugin-help.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Skippy Maven Plugin 5 | Skippy Maven Plugin 6 | io.skippy 7 | skippy-maven 8 | @skippy.version@ 9 | skippy 10 | 11 | 12 | clean 13 | true 14 | true 15 | false 16 | false 17 | false 18 | true 19 | initialize 20 | io.skippy.maven.SkippyCleanMojo 21 | java 22 | per-lookup 23 | once-per-session 24 | false 25 | 26 | 27 | 28 | buildStarted 29 | false 30 | true 31 | false 32 | false 33 | false 34 | true 35 | initialize 36 | io.skippy.maven.SkippyBuildStartedMojo 37 | java 38 | per-lookup 39 | once-per-session 40 | false 41 | 42 | 43 | 44 | buildFinished 45 | false 46 | true 47 | false 48 | false 49 | false 50 | true 51 | test 52 | io.skippy.maven.SkippyBuildFinishedMojo 53 | java 54 | per-lookup 55 | once-per-session 56 | false 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /skippy-junit4/src/main/java/io/skippy/junit4/CoverageFileRule.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit4; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import io.skippy.core.TestTag; 21 | import org.junit.rules.TestRule; 22 | import org.junit.runner.Description; 23 | import org.junit.runners.model.MultipleFailureException; 24 | import org.junit.runners.model.Statement; 25 | 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | 29 | /** 30 | * Modified version of {@link org.junit.rules.ExternalResource} that triggers the capture of coverage data for a test 31 | * class. 32 | * 33 | * @author Florian McKee 34 | */ 35 | class CoverageFileRule implements TestRule { 36 | 37 | private final SkippyTestApi skippyTestApi = SkippyTestApi.INSTANCE; 38 | 39 | public Statement apply(Statement base, Description description) { 40 | return statement(base, description); 41 | } 42 | 43 | private Statement statement(final Statement base, Description description) { 44 | return new Statement() { 45 | @Override 46 | public void evaluate() throws Throwable { 47 | skippyTestApi.before(description.getTestClass(), description.getMethodName()); 48 | List errors = new ArrayList(); 49 | try { 50 | base.evaluate(); 51 | } catch (Throwable t) { 52 | skippyTestApi.tagTest(description.getTestClass(), TestTag.FAILED); 53 | errors.add(t); 54 | } finally { 55 | try { 56 | skippyTestApi.after(description.getTestClass(), description.getMethodName()); 57 | } catch (Throwable t) { 58 | errors.add(t); 59 | } 60 | } 61 | MultipleFailureException.assertEmpty(errors); 62 | } 63 | }; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/ClassUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.nio.file.Files; 22 | import java.nio.file.Path; 23 | 24 | import static org.junit.jupiter.api.Assertions.assertEquals; 25 | 26 | /** 27 | * Tests for {@link ClassUtil}. 28 | * 29 | * @author Florian McKee 30 | */ 31 | public class ClassUtilTest { 32 | 33 | @Test 34 | void testFetFullyQualifiedClassName() { 35 | var classFile = Path.of("build/classes/java/test").resolve("com/example/StringUtils.class"); 36 | assertEquals("com.example.StringUtils", ClassUtil.getFullyQualifiedClassName(classFile)); 37 | } 38 | 39 | @Test 40 | void testGetOutputFolder() throws Exception { 41 | assertEquals(Path.of("build/classes/java/test"), ClassUtil.getOutputFolder(Path.of(""), Class.forName("com.example.LeftPadderTest"))); 42 | } 43 | 44 | @Test 45 | void testLocationAvailable() throws Exception { 46 | assertEquals(true, ClassUtil.locationAvailable(Class.forName("com.example.LeftPadderTest"))); 47 | } 48 | 49 | @Test 50 | void testLocationAvailableUnavailable() throws Exception { 51 | 52 | // the class loading gymnastics below create a Class object w/o location information 53 | var location = Class.forName("com.example.LeftPadderTest").getProtectionDomain().getCodeSource().getLocation(); 54 | var pathToClassFile = Path.of(location.toURI()).resolve("com").resolve("example").resolve("LeftPadderTest.class"); 55 | 56 | class ByteArrayClassLoader extends ClassLoader { 57 | public Class loadClassFromBytes(String className, byte[] classBytes) { 58 | return defineClass(className, classBytes, 0, classBytes.length); 59 | } 60 | } 61 | 62 | var testClass = new ByteArrayClassLoader().loadClassFromBytes("com.example.LeftPadderTest", Files.readAllBytes(pathToClassFile)); 63 | assertEquals(false, ClassUtil.locationAvailable(testClass)); 64 | } 65 | } -------------------------------------------------------------------------------- /.github/workflows/gradle-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created 6 | # For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle 7 | 8 | name: Gradle Package 9 | 10 | on: 11 | release: 12 | types: [created] 13 | 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up JDK 17 25 | uses: actions/setup-java@v3 26 | with: 27 | java-version: '17' 28 | distribution: 'temurin' 29 | server-id: github # Value of the distributionManagement/repository/id field of the pom.xml 30 | settings-path: ${{ github.workspace }} # location for the settings.xml file 31 | 32 | - name: Build with Gradle 33 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 34 | with: 35 | arguments: build 36 | env: 37 | SKIPPY_PRIVATE_KEY: ${{ secrets.SKIPPY_PRIVATE_KEY }} 38 | SKIPPY_PRIVATE_KEY_SECRET: ${{ secrets.SKIPPY_PRIVATE_KEY_SECRET }} 39 | 40 | - name: Deploy release to Sonatype OSSRH (OSS Repository Hosting) 41 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 42 | with: 43 | arguments: publish -PpublishToGradlePluginPortal=false 44 | env: 45 | OSSRH_TOKEN: ${{ secrets.OSSRH_TOKEN }} 46 | OSSRH_TOKEN_SECRET: ${{ secrets.OSSRH_TOKEN_SECRET }} 47 | SKIPPY_PRIVATE_KEY: ${{ secrets.SKIPPY_PRIVATE_KEY }} 48 | SKIPPY_PRIVATE_KEY_SECRET: ${{ secrets.SKIPPY_PRIVATE_KEY_SECRET }} 49 | 50 | - name: Deploy skippy-gradle to Gradle Plugin Portal 51 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 52 | with: 53 | arguments: :skippy-gradle:publishPlugins -PpublishToGradlePluginPortal=true -Pgradle.publish.key=${{ secrets.GRADLE_PLUGIN_PORTAL_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_PLUGIN_PORTAL_SECRET }} 54 | 55 | - name: Deploy skippy-gradle-android to Gradle Plugin Portal 56 | uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 57 | with: 58 | arguments: :skippy-gradle-android:publishPlugins -PpublishToGradlePluginPortal=true -Pgradle.publish.key=${{ secrets.GRADLE_PLUGIN_PORTAL_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_PLUGIN_PORTAL_SECRET }} -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/SkippyRepositoryExtension.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.Optional; 20 | 21 | /** 22 | * Extension point that allows projects to customize retrieval and storage of {@link TestImpactAnalysis} instances and 23 | * JaCoCo execution data files. 24 | *

25 | * Custom implementations must have a public constructor that accepts a single argument of type {@link java.nio.file.Path}. 26 | * Skippy will pass the project directory when the instance is created. 27 | *

28 | * Custom implementations must be registered using Skippy's build plugins. 29 | *

30 | * Gradle example: 31 | *
32 |  * skippy {
33 |  *     ...
34 |  *     repository = 'com.example.S3SkippyRepository'
35 |  * }
36 |  * 
37 | * 38 | * @author Florian McKee 39 | */ 40 | public interface SkippyRepositoryExtension { 41 | 42 | /** 43 | * Retrieves a {@link TestImpactAnalysis} by {@code id}. 44 | * 45 | * @param id must not be null 46 | * @return the {@link TestImpactAnalysis} with the given {@code id} or {@link Optional#empty()} if none found 47 | */ 48 | Optional findTestImpactAnalysis(String id); 49 | 50 | /** 51 | * Saves a given {@link TestImpactAnalysis}. 52 | * 53 | * @param testImpactAnalysis must not be null 54 | */ 55 | void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis); 56 | 57 | 58 | /** 59 | * Retrieves JaCoCo execution data by {@code id}. 60 | * 61 | * @param executionId a unique identifier for the execution data 62 | * @return the JaCoCo execution data with the given {@code executionId} or {@link Optional#empty()} if none found 63 | */ 64 | Optional findJacocoExecutionData(String executionId); 65 | 66 | /** 67 | * Saves JaCoCo execution data. 68 | * 69 | * @param executionId a unique identifier for the execution data 70 | * @param jacocoExecutionData must not be null 71 | */ 72 | void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData); 73 | } -------------------------------------------------------------------------------- /skippy-maven/src/main/java/io/skippy/maven/SkippyBuildStartedMojo.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.maven; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import io.skippy.core.SkippyConfiguration; 21 | import io.skippy.core.SkippyRepository; 22 | import org.apache.maven.execution.MavenSession; 23 | import org.apache.maven.plugin.AbstractMojo; 24 | import org.apache.maven.plugins.annotations.Component; 25 | import org.apache.maven.plugins.annotations.LifecyclePhase; 26 | import org.apache.maven.plugins.annotations.Mojo; 27 | import org.apache.maven.plugins.annotations.Parameter; 28 | import org.apache.maven.project.MavenProject; 29 | 30 | import java.nio.file.Path; 31 | import java.util.Optional; 32 | 33 | /** 34 | * Mojo that informs Skippy that a build has started. 35 | */ 36 | @Mojo(name = "buildStarted", defaultPhase = LifecyclePhase.INITIALIZE) 37 | public class SkippyBuildStartedMojo extends AbstractMojo { 38 | 39 | @Parameter(defaultValue = "${project}", required = true, readonly = true) 40 | MavenProject project; 41 | 42 | @Parameter(defaultValue = "false", property = "coverageForSkippedTests") 43 | private boolean coverageForSkippedTests; 44 | 45 | @Parameter(property = "repository") 46 | private String repository; 47 | 48 | @Parameter(property = "predictionModifier") 49 | private String predictionModifier; 50 | 51 | @Component 52 | private MavenSession session; 53 | 54 | @Override 55 | public void execute() { 56 | var projectDir = project.getBasedir().toPath(); 57 | var skippyConfiguration = new SkippyConfiguration( 58 | coverageForSkippedTests, 59 | Optional.ofNullable(repository), 60 | Optional.ofNullable(predictionModifier) 61 | ); 62 | var skippyApi = new SkippyBuildApi( 63 | skippyConfiguration, 64 | new MavenClassFileCollector(project), 65 | SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent())) 66 | ); 67 | skippyApi.buildStarted(); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/GradleClassFileCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.ClassFileCollector; 20 | import io.skippy.core.ClassFile; 21 | import io.skippy.core.Profiler; 22 | 23 | import java.io.File; 24 | import java.nio.file.Path; 25 | import java.util.*; 26 | 27 | /** 28 | * Collects {@link ClassFile}s across all output folders in a project. 29 | * 30 | * @author Florian McKee 31 | */ 32 | final class GradleClassFileCollector implements ClassFileCollector { 33 | 34 | private final Path projectDir; 35 | private final List outputFolders; 36 | 37 | GradleClassFileCollector(Path projectDir, List outputFolders) { 38 | this.projectDir = projectDir; 39 | this.outputFolders = outputFolders; 40 | } 41 | 42 | /** 43 | * Collects all {@link ClassFile}s in the output folders of the project. 44 | * 45 | * @return all {@link ClassFile}s in the output folders of the project 46 | */ 47 | @Override 48 | public List collect() { 49 | return Profiler.profile("GradleClassFileCollector#collect", () -> { 50 | var result = new ArrayList(); 51 | for (var outputFolder : outputFolders) { 52 | result.addAll(sort(collect(outputFolder, outputFolder))); 53 | } 54 | return result; 55 | }); 56 | } 57 | 58 | private List collect(File outputFolder, File directory) { 59 | var result = new LinkedList(); 60 | File[] files = directory.listFiles(); 61 | if (files != null) { 62 | for (File file : files) { 63 | if (file.isDirectory()) { 64 | result.addAll(collect(outputFolder, file)); 65 | } else if (file.getName().endsWith(".class")) { 66 | result.add(ClassFile.fromFileSystem(projectDir, outputFolder.toPath(), file.toPath())); 67 | } 68 | } 69 | } 70 | return result; 71 | } 72 | 73 | private List sort(List input) { 74 | return input.stream() 75 | .sorted() 76 | .toList(); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /skippy-maven/src/main/java/io/skippy/maven/SkippyBuildFinishedMojo.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.maven; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import io.skippy.core.SkippyConfiguration; 21 | import io.skippy.core.SkippyRepository; 22 | import org.apache.maven.execution.MavenSession; 23 | import org.apache.maven.plugin.AbstractMojo; 24 | import org.apache.maven.plugins.annotations.Component; 25 | import org.apache.maven.plugins.annotations.LifecyclePhase; 26 | import org.apache.maven.plugins.annotations.Mojo; 27 | import org.apache.maven.plugins.annotations.Parameter; 28 | import org.apache.maven.project.MavenProject; 29 | 30 | import java.nio.file.Path; 31 | import java.util.Optional; 32 | 33 | /** 34 | * Mojo that informs Skippy that the parts of the build that are relevant for Skippy (e.g., compilation and test 35 | * execution) have finished. 36 | */ 37 | @Mojo(name = "buildFinished", defaultPhase = LifecyclePhase.TEST) 38 | public class SkippyBuildFinishedMojo extends AbstractMojo { 39 | 40 | @Parameter(defaultValue = "${project}", required = true, readonly = true) 41 | MavenProject project; 42 | 43 | @Parameter(defaultValue = "false", property = "coverageForSkippedTests") 44 | private boolean coverageForSkippedTests; 45 | 46 | @Parameter(property = "repository") 47 | private String repository; 48 | 49 | @Parameter(property = "predictionModifier") 50 | private String predictionModifier; 51 | 52 | @Component 53 | private MavenSession session; 54 | 55 | @Override 56 | public void execute() { 57 | var projectDir = project.getBasedir().toPath(); 58 | var skippyConfiguration = new SkippyConfiguration( 59 | coverageForSkippedTests, 60 | Optional.ofNullable(repository), 61 | Optional.ofNullable(predictionModifier) 62 | ); 63 | var skippyApi = new SkippyBuildApi( 64 | skippyConfiguration, 65 | new MavenClassFileCollector(project), 66 | SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent())) 67 | ); 68 | skippyApi.buildFinished(); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /skippy-maven/src/main/java/io/skippy/maven/SkippyCleanMojo.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.maven; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import io.skippy.core.SkippyConfiguration; 21 | import io.skippy.core.SkippyRepository; 22 | import org.apache.maven.execution.MavenSession; 23 | import org.apache.maven.plugin.AbstractMojo; 24 | import org.apache.maven.plugins.annotations.Component; 25 | import org.apache.maven.plugins.annotations.LifecyclePhase; 26 | import org.apache.maven.plugins.annotations.Mojo; 27 | import org.apache.maven.plugins.annotations.Parameter; 28 | import org.apache.maven.project.MavenProject; 29 | 30 | import java.nio.file.Path; 31 | import java.util.Optional; 32 | 33 | /** 34 | * Resets the skippy folder: After completion, only an up-to-date config.json will remain. 35 | *

36 | * Direct invocation: {@code mvn skippy:clean} 37 | * 38 | * @author Florian McKee 39 | */ 40 | @Mojo(name = "clean", defaultPhase = LifecyclePhase.INITIALIZE) 41 | public class SkippyCleanMojo extends AbstractMojo { 42 | 43 | @Parameter(defaultValue = "${project}", required = true, readonly = true) 44 | MavenProject project; 45 | 46 | @Parameter(defaultValue = "false", property = "coverageForSkippedTests") 47 | private boolean coverageForSkippedTests; 48 | 49 | @Parameter(property = "repository") 50 | private String repository; 51 | 52 | @Parameter(property = "predictionModifier") 53 | private String predictionModifier; 54 | 55 | @Component 56 | private MavenSession session; 57 | 58 | @Override 59 | public void execute() { 60 | var projectDir = project.getBasedir().toPath(); 61 | var skippyConfiguration = new SkippyConfiguration( 62 | coverageForSkippedTests, 63 | Optional.ofNullable(repository), 64 | Optional.ofNullable(predictionModifier) 65 | ); 66 | var skippyApi = new SkippyBuildApi( 67 | skippyConfiguration, 68 | new MavenClassFileCollector(project), 69 | SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent())) 70 | ); 71 | skippyApi.resetSkippyFolder(); 72 | } 73 | 74 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/ProjectSettings.java: -------------------------------------------------------------------------------- 1 | package io.skippy.gradle.android; 2 | 3 | import org.gradle.api.Project; 4 | 5 | import java.io.File; 6 | import java.io.Serializable; 7 | import java.nio.file.Path; 8 | import java.util.List; 9 | import java.util.function.Consumer; 10 | import java.util.stream.Stream; 11 | 12 | import io.skippy.core.SkippyBuildApi; 13 | import io.skippy.core.SkippyRepository; 14 | 15 | /** 16 | * A sub-set of relevant {@link Project} properties that are compatible with Gradle's Configuration Cache. 17 | * 18 | * @author Florian McKee 19 | * @author Eugeniu Tufar 20 | */ 21 | class ProjectSettings implements Serializable { 22 | 23 | final List classesDirs; 24 | final SkippyPluginExtension skippyPluginExtension; 25 | final Path projectDir; 26 | final Path buildDir; 27 | 28 | /** 29 | * C'tor. 30 | * 31 | * @param classesDirs the folders that contain compiled class files 32 | * @param skippyExtension the {@link SkippyPluginExtension} 33 | * @param projectDir the project directory (e.g., /repos/my-project) 34 | * @param buildDir the build directory (e.g., /repos/my-project/build) 35 | */ 36 | private ProjectSettings(List classesDirs, SkippyPluginExtension skippyExtension, Path projectDir, Path buildDir) { 37 | this.classesDirs = classesDirs; 38 | this.skippyPluginExtension = skippyExtension; 39 | this.projectDir = projectDir; 40 | this.buildDir = buildDir; 41 | } 42 | 43 | static ProjectSettings from(Project project) { 44 | var androidClassesDirs = AndroidDestinationDirectoryCollector.collect(project); 45 | var kotlinClassesDirs = KotlinDestinationDirectoryCollector.collect(project); 46 | var allClassesDirs = Stream.concat(kotlinClassesDirs, androidClassesDirs).toList(); 47 | 48 | Path projectDir = project.getProjectDir().toPath(); 49 | Path buildDir = project.getLayout().getBuildDirectory().getAsFile().get().toPath(); 50 | var skippyExtension = project.getExtensions().getByType(SkippyPluginExtension.class); 51 | return new ProjectSettings(allClassesDirs, skippyExtension, projectDir, buildDir); 52 | } 53 | 54 | void ifBuildSupportsSkippy(Consumer action) { 55 | if (classesDirs != null && !classesDirs.isEmpty()) { 56 | var skippyConfiguration = skippyPluginExtension.toSkippyConfiguration(); 57 | var skippyBuildApi = new SkippyBuildApi( 58 | skippyConfiguration, 59 | new GradleClassFileCollector(projectDir, classesDirs), 60 | SkippyRepository.getInstance( 61 | skippyConfiguration, 62 | projectDir, 63 | buildDir 64 | ) 65 | ); 66 | action.accept(skippyBuildApi); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /skippy-maven/src/main/java/io/skippy/maven/MavenClassFileCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.maven; 18 | 19 | import io.skippy.core.ClassFile; 20 | import io.skippy.core.ClassFileCollector; 21 | import org.apache.maven.project.MavenProject; 22 | 23 | import java.io.File; 24 | import java.util.ArrayList; 25 | import java.util.LinkedList; 26 | import java.util.List; 27 | 28 | import static java.util.Comparator.comparing; 29 | 30 | /** 31 | * Collects {@link ClassFile}s in the output directories of the project. 32 | * 33 | * @author Florian McKee 34 | */ 35 | final class MavenClassFileCollector implements ClassFileCollector { 36 | 37 | private final MavenProject project; 38 | 39 | MavenClassFileCollector(MavenProject project) { 40 | this.project = project; 41 | } 42 | 43 | /** 44 | * Collects all {@link ClassFile}s in the output directories of the project. 45 | * 46 | * @return all {@link ClassFile}s in the output directories of the project. 47 | */ 48 | @Override 49 | public List collect() { 50 | var classesDir = new File(project.getBuild().getOutputDirectory()); 51 | var testClassesDir = new File(project.getBuild().getTestOutputDirectory()); 52 | var result = new ArrayList(); 53 | result.addAll(sort(collect(classesDir, classesDir))); 54 | result.addAll(sort(collect(testClassesDir, testClassesDir))); 55 | return result; 56 | } 57 | 58 | private List collect(File outputFolder, File searchDirectory) { 59 | var result = new LinkedList(); 60 | File[] files = searchDirectory.listFiles(); 61 | if (files != null) { 62 | for (File file : files) { 63 | if (file.isDirectory()) { 64 | result.addAll(collect(outputFolder, file)); 65 | } else if (file.getName().endsWith(".class")) { 66 | result.add(ClassFile.fromFileSystem(project.getBasedir().toPath(), outputFolder.toPath(), file.toPath())); 67 | } 68 | } 69 | } 70 | return result; 71 | } 72 | 73 | private List sort(List input) { 74 | return input.stream() 75 | .sorted() 76 | .toList(); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /skippy-gradle-android/src/main/java/io/skippy/gradle/android/GradleClassFileCollector.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle.android; 18 | 19 | import io.skippy.core.ClassFileCollector; 20 | import io.skippy.core.ClassFile; 21 | import io.skippy.core.Profiler; 22 | 23 | import java.io.File; 24 | import java.nio.file.Path; 25 | import java.util.*; 26 | 27 | /** 28 | * Collects {@link ClassFile}s across all destination directories of the build tasks in a project. 29 | * 30 | * @author Florian McKee 31 | */ 32 | final class GradleClassFileCollector implements ClassFileCollector { 33 | 34 | private final Path projectDir; 35 | private final List destinationDirectories; 36 | 37 | GradleClassFileCollector(Path projectDir, List destinationDirectories) { 38 | this.projectDir = projectDir; 39 | this.destinationDirectories = destinationDirectories; 40 | } 41 | 42 | /** 43 | * Collects all {@link ClassFile}s in the project's destination directories. 44 | * 45 | * @return all {@link ClassFile}s in the project's destination directories. 46 | */ 47 | @Override 48 | public List collect() { 49 | return Profiler.profile("GradleClassFileCollector#collect", () -> { 50 | var result = new ArrayList(); 51 | for (var classesDir : destinationDirectories) { 52 | result.addAll(sort(collect(classesDir, classesDir))); 53 | } 54 | return result; 55 | }); 56 | } 57 | 58 | private List collect(File outputFolder, File directory) { 59 | var result = new LinkedList(); 60 | File[] files = directory.listFiles(); 61 | if (files != null) { 62 | for (File file : files) { 63 | if (file.isDirectory()) { 64 | result.addAll(collect(outputFolder, file)); 65 | } else if (file.getName().endsWith(".class")) { 66 | result.add(ClassFile.fromFileSystem(projectDir, outputFolder.toPath(), file.toPath())); 67 | } 68 | } 69 | } 70 | return result; 71 | } 72 | 73 | private List sort(List input) { 74 | return input.stream() 75 | .sorted() 76 | .toList(); 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/TokenizerTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | import org.junit.jupiter.params.ParameterizedTest; 21 | import org.junit.jupiter.params.provider.CsvSource; 22 | 23 | import static org.junit.jupiter.api.Assertions.assertEquals; 24 | import static org.junit.jupiter.api.Assertions.assertThrows; 25 | 26 | public class TokenizerTest { 27 | 28 | @ParameterizedTest 29 | @CsvSource(value = { 30 | "[{", 31 | " [{" 32 | }, delimiter = ':') 33 | void testSkip(String stream) { 34 | var tokenizer = new Tokenizer(stream); 35 | tokenizer.skip('['); 36 | assertEquals("{", tokenizer.asString()); 37 | } 38 | 39 | @Test 40 | void testInvalidSkip() { 41 | var tokenizer = new Tokenizer("a"); 42 | var ex = assertThrows(IllegalStateException.class, () -> tokenizer.skip('b')); 43 | assertEquals("Can't skip over 'b' in residual characters 'a'.", ex.getMessage()); 44 | } 45 | 46 | @Test 47 | void testSkipConsumesLeadingWhitespaces() { 48 | var tokenizer = new Tokenizer(" [ foo"); 49 | tokenizer.skip('['); 50 | assertEquals(" foo", tokenizer.asString()); 51 | } 52 | 53 | @ParameterizedTest 54 | @CsvSource(value = { 55 | "{:{", 56 | "\"value\":value", 57 | "123:123" 58 | }, delimiter = ':') 59 | void testNext(String stream, String expectedNextToken) { 60 | var tokenizer = new Tokenizer(stream); 61 | assertEquals(expectedNextToken, tokenizer.next()); 62 | } 63 | 64 | @ParameterizedTest 65 | @CsvSource(value = { 66 | "ab:a:true", 67 | "ab:b:false", 68 | " ab:a:true", 69 | " ab:b:false" 70 | }, delimiter = ':') 71 | void testPeek(String stream, char search, boolean expected) { 72 | var tokenizer = new Tokenizer(stream); 73 | assertEquals(expected, tokenizer.peek(search)); 74 | } 75 | 76 | @Test 77 | void testTokenWithWhitespace() { 78 | var tokenizer = new Tokenizer(""" 79 | "name": "foo bar" 80 | """); 81 | assertEquals("name", tokenizer.next()); 82 | tokenizer.skip(':'); 83 | assertEquals("foo bar", tokenizer.next()); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /skippy-junit5/src/test/java/io/skippy/junit5/SkipOrExecuteConditionTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.junit5; 18 | 19 | import io.skippy.core.SkippyTestApi; 20 | import org.junit.jupiter.api.Test; 21 | import org.junit.jupiter.api.extension.ExtensionContext; 22 | 23 | import java.util.Optional; 24 | 25 | import static org.junit.jupiter.api.Assertions.assertEquals; 26 | import static org.mockito.ArgumentMatchers.any; 27 | import static org.mockito.Mockito.mock; 28 | import static org.mockito.Mockito.when; 29 | 30 | /** 31 | * Tests for {@link SkipOrExecuteCondition}. 32 | * 33 | * @author Florian McKee 34 | */ 35 | public class SkipOrExecuteConditionTest { 36 | 37 | @Test 38 | void testEmptyTestInstanceEqualsEnabled() { 39 | var skippyTestApi = mock(SkippyTestApi.class); 40 | var skippyExecutionCondition = new SkipOrExecuteCondition(skippyTestApi); 41 | ExtensionContext context = mock(ExtensionContext.class); 42 | 43 | when(context.getTestInstance()).thenReturn(Optional.empty()); 44 | 45 | assertEquals(false, skippyExecutionCondition.evaluateExecutionCondition(context).isDisabled()); 46 | } 47 | 48 | @Test 49 | void testSkippyAnalysisExecutionRequiredFalse() { 50 | var skippyTestApi = mock(SkippyTestApi.class); 51 | var skippyExecutionCondition = new SkipOrExecuteCondition(skippyTestApi); 52 | ExtensionContext context = mock(ExtensionContext.class); 53 | 54 | when(context.getTestInstance()).thenReturn(Optional.of(new Object())); 55 | when(context.getTestClass()).thenReturn(Optional.of(Object.class)); 56 | when(skippyTestApi.testNeedsToBeExecuted(any())).thenReturn(false); 57 | 58 | assertEquals(true, skippyExecutionCondition.evaluateExecutionCondition(context).isDisabled()); 59 | } 60 | 61 | @Test 62 | void testSkippyAnalysisExecutionRequiredTrue() { 63 | var skippyTestApi = mock(SkippyTestApi.class); 64 | var skippyExecutionCondition = new SkipOrExecuteCondition(skippyTestApi); 65 | ExtensionContext context = mock(ExtensionContext.class); 66 | 67 | when(context.getTestInstance()).thenReturn(Optional.of(new Object())); 68 | when(context.getTestClass()).thenReturn(Optional.of(Object.class)); 69 | when(skippyTestApi.testNeedsToBeExecuted(any())).thenReturn(true); 70 | 71 | assertEquals(false, skippyExecutionCondition.evaluateExecutionCondition(context).isDisabled()); 72 | } 73 | 74 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/Reason.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.Optional; 20 | 21 | /** 22 | * The reason for a {@link Prediction}. 23 | * 24 | * @author Florian McKee 25 | */ 26 | public record Reason(Category category, Optional details) { 27 | 28 | /** 29 | * The high-level reason. 30 | */ 31 | public enum Category { 32 | 33 | /** 34 | * Skippy was unable to retrieve an existing Test Impact Analysis to make a skip-or-execute decision. 35 | */ 36 | TEST_IMPACT_ANALYSIS_NOT_FOUND, 37 | 38 | /** 39 | * Neither the test nor any of the covered classes have changed. 40 | */ 41 | NO_CHANGE, 42 | 43 | /** 44 | * The test hasn't been analyzed before. 45 | */ 46 | NO_IMPACT_DATA_FOUND_FOR_TEST, 47 | 48 | /** 49 | * Bytecode change in test detected. 50 | */ 51 | BYTECODE_CHANGE_IN_TEST, 52 | 53 | /** 54 | * Bytecode change in covered class detected. 55 | */ 56 | BYTECODE_CHANGE_IN_COVERED_CLASS, 57 | 58 | /** 59 | * The test failed previously. 60 | */ 61 | TEST_FAILED_PREVIOUSLY, 62 | 63 | /** 64 | * An error occurred in the prediction logic. 65 | */ 66 | INTERNAL_ERROR_IN_PREDICTION_LOGIC, 67 | 68 | /** 69 | * The test is tagged as {@link TestTag#ALWAYS_EXECUTE}. 70 | */ 71 | TEST_TAGGED_AS_ALWAYS_EXECUTE, 72 | 73 | /** 74 | * A covered test is tagged as {@link TestTag#FAILED}. 75 | *
76 | * This is relevant for JUnit 5's @Nested tests. 77 | */ 78 | COVERED_TEST_TAGGED_AS_FAILED, 79 | 80 | /** 81 | * A covered test is tagged as {@link TestTag#ALWAYS_EXECUTE}. 82 | *
83 | * This is relevant for JUnit 5's @Nested tests. 84 | */ 85 | COVERED_TEST_TAGGED_AS_ALWAYS_EXECUTE, 86 | 87 | /** 88 | * The class file of the test was not found on the file system. 89 | */ 90 | TEST_CLASS_CLASS_FILE_NOT_FOUND, 91 | 92 | /** 93 | * Coverage for skipped tests is enabled but the test has no execution id. The test needs to be re-run in order 94 | * to capture coverage for skipped tests. 95 | */ 96 | MISSING_EXECUTION_ID, 97 | 98 | /** 99 | * Coverage for skipped tests is enabled and the test has an execution id. However, Skippy is unable to read the 100 | * execution data. The test needs to be re-run in order to capture coverage for skipped tests. 101 | */ 102 | UNABLE_TO_READ_EXECUTION_DATA, 103 | 104 | 105 | /** 106 | * The default prediction was overridden by a {@link PredictionModifier}. 107 | */ 108 | OVERRIDE_BY_PREDICTION_MODIFIER 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/Tokenizer.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.Arrays; 20 | 21 | import static java.lang.Character.isDigit; 22 | 23 | /** 24 | * Home-grown JSON tokenization to avoid a transitive dependencies to Jackson (or some other JSON library). 25 | * 26 | * @author Florian McKee 27 | */ 28 | final class Tokenizer { 29 | 30 | private final char[] stream; 31 | private int head; 32 | private final int tail; 33 | 34 | Tokenizer(String input) { 35 | this.stream = input.replaceAll("[\\s&&[^ ]]", "").toCharArray(); 36 | this.head = 0; 37 | this.tail = stream.length; 38 | } 39 | 40 | @Override 41 | public String toString() { 42 | return asString(); 43 | 44 | } 45 | String asString() { 46 | return new String(Arrays.copyOfRange(stream, head, tail)); 47 | } 48 | 49 | void skip(char c) { 50 | skipLeadingWhitespaces(); 51 | if (head == tail || stream[head] != c) { 52 | throw new IllegalStateException("Can't skip over '%s' in residual characters '%s'.".formatted(c, asString())); 53 | } 54 | head++; 55 | } 56 | 57 | String next() { 58 | skipLeadingWhitespaces(); 59 | if (peek('{')) { 60 | head++; 61 | return "{"; 62 | } 63 | // read string 64 | if (peek('"')) { 65 | int pointer = head + 1; 66 | while (stream[pointer] != '"') { 67 | pointer++; 68 | } 69 | var result = new String(Arrays.copyOfRange(stream, head + 1, pointer)); 70 | head = pointer + 1; 71 | return result; 72 | } 73 | 74 | // read number 75 | if (peekDigit()) { 76 | int pointer = head; 77 | while (pointer < stream.length && isDigit(stream[pointer])) { 78 | pointer++; 79 | } 80 | var result = new String(Arrays.copyOfRange(stream, head, pointer)); 81 | head = pointer; 82 | return result; 83 | } 84 | throw new IllegalStateException("Unable to determine next token in residual characters '%s'.".formatted(asString())); 85 | } 86 | 87 | boolean peek(char c) { 88 | skipLeadingWhitespaces(); 89 | if (head == tail) { 90 | return false; 91 | } 92 | return stream[head] == c; 93 | } 94 | 95 | boolean peekDigit() { 96 | skipLeadingWhitespaces(); 97 | if (head == tail) { 98 | return false; 99 | } 100 | return isDigit(stream[head]); 101 | } 102 | 103 | void skipIfNext(char c) { 104 | skipLeadingWhitespaces(); 105 | if (head == tail) { 106 | return; 107 | } 108 | if (stream[head] == c) { 109 | head++; 110 | } 111 | } 112 | private void skipLeadingWhitespaces() { 113 | while (head != tail && stream[head] == ' ') { 114 | head++; 115 | } 116 | } 117 | 118 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/ClassUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.objectweb.asm.ClassReader; 20 | import org.objectweb.asm.ClassVisitor; 21 | import org.objectweb.asm.Opcodes; 22 | 23 | import java.io.IOException; 24 | import java.io.UncheckedIOException; 25 | import java.net.URISyntaxException; 26 | import java.nio.file.Path; 27 | import java.util.concurrent.atomic.AtomicReference; 28 | 29 | import static java.nio.file.Files.newInputStream; 30 | 31 | /** 32 | * Static utility methods that primarily operate on {@link Class} objects. 33 | * 34 | * @author Florian McKee 35 | */ 36 | class ClassUtil { 37 | 38 | /** 39 | * Return the output folder of {@code clazz} relative to the {@code projectFolder}. 40 | * 41 | * @param projectFolder (e.g. /home/foo/repos/my-project) 42 | * @param clazz (e.g., com.example.Foo) 43 | * @return the output folder of {@code clazz} relative to the {@code projectFolder} (e.g., build/classes/java/test) 44 | */ 45 | static Path getOutputFolder(Path projectFolder, Class clazz) { 46 | try { 47 | return projectFolder.toAbsolutePath().relativize((Path.of(clazz.getProtectionDomain().getCodeSource().getLocation().toURI()))); 48 | } catch (URISyntaxException e) { 49 | throw new RuntimeException("Unable to obtain output folder for class %s in project %s: %s".formatted(clazz.getName(), projectFolder, e), e); 50 | } 51 | } 52 | 53 | /** 54 | * Returns {@code true} if the location for {@code clazz} is available, {@code false} otherwise. 55 | * 56 | * @param clazz a {@link Class} object 57 | * @return {@code true} if the location for {@code clazz} is available, {@code false} otherwise 58 | */ 59 | static boolean locationAvailable(Class clazz) { 60 | try { 61 | return clazz.getProtectionDomain().getCodeSource().getLocation() != null; 62 | } catch (Exception e) { 63 | return false; 64 | } 65 | } 66 | 67 | /** 68 | * Extracts the fully-qualified class name (e.g., com.example.Foo) from the {@code classFile}. 69 | * 70 | * @param classFile a class file 71 | * @return the fully-qualified class name (e.g., com.example.Foo) of the {@code classFile} 72 | */ 73 | static String getFullyQualifiedClassName(Path classFile) { 74 | var className = new AtomicReference(); 75 | try (var inputStream = newInputStream(classFile)) { 76 | new ClassReader(inputStream).accept(createClassVisitor(className), 0); 77 | return className.get(); 78 | } catch (IOException e) { 79 | throw new UncheckedIOException("Unable to obtain fully qualified class name from class file %s.".formatted(classFile), e); 80 | } 81 | } 82 | 83 | private static ClassVisitor createClassVisitor(AtomicReference className) { 84 | return new ClassVisitor(Opcodes.ASM9) { 85 | @Override 86 | public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { 87 | className.set(name.replace('/', '.')); 88 | } 89 | }; 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/JacocoExecutionDataUtilTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.io.IOException; 22 | import java.net.URISyntaxException; 23 | import java.nio.file.Files; 24 | import java.nio.file.Path; 25 | 26 | import static java.util.Arrays.asList; 27 | import static org.junit.jupiter.api.Assertions.assertEquals; 28 | 29 | public class JacocoExecutionDataUtilTest { 30 | 31 | @Test 32 | void testGetExecutionId() throws URISyntaxException, IOException { 33 | var leftPadderTestExecutionDataFile = Path.of(getClass().getResource("com.example.LeftPadderTest.exec").toURI()); 34 | assertEquals("F94F1606CFCA75C46D4E2CECF86DD5C4", HashUtil.hashWith32Digits(Files.readAllBytes(leftPadderTestExecutionDataFile))); 35 | assertEquals("D40016DC6B856D89EA17DB14F370D026", JacocoUtil.getExecutionId(Files.readAllBytes(leftPadderTestExecutionDataFile))); 36 | 37 | // getExecutionId yields the same id if the only difference between two execution data instances is the session info block 38 | var leftPadderTestExecutionDataFileForExecution2 = Path.of(getClass().getResource("com.example.LeftPadderTest-run2.exec").toURI()); 39 | assertEquals("ACE148F18B1D3DCC623160C6CF0849A4", HashUtil.hashWith32Digits(Files.readAllBytes(leftPadderTestExecutionDataFileForExecution2))); 40 | assertEquals("D40016DC6B856D89EA17DB14F370D026", JacocoUtil.getExecutionId(Files.readAllBytes(leftPadderTestExecutionDataFileForExecution2))); 41 | 42 | var rightPadderTestExecutionDataFile = Path.of(getClass().getResource("com.example.RightPadderTest.exec").toURI()); 43 | assertEquals("8AF444DB651C3930E724886027566607", JacocoUtil.getExecutionId(Files.readAllBytes(rightPadderTestExecutionDataFile))); 44 | } 45 | @Test 46 | 47 | void testGetCoveredClasses() throws URISyntaxException, IOException { 48 | var leftPadderTestExecutionDataFile = Path.of(getClass().getResource("com.example.LeftPadderTest.exec").toURI()); 49 | var coveredClasses = JacocoUtil.getCoveredClasses(Files.readAllBytes(leftPadderTestExecutionDataFile)).stream() 50 | .filter(clazz -> clazz.className().startsWith("com.example")) 51 | .toList(); 52 | 53 | assertEquals(asList( 54 | new ClassNameAndJaCoCoId("com.example.LeftPadder", -6866071476670293317L), 55 | new ClassNameAndJaCoCoId("com.example.LeftPadderTest", -7420687749271300115L), 56 | new ClassNameAndJaCoCoId("com.example.StringUtils", 3772932961599681095L) 57 | ), coveredClasses); 58 | 59 | var rightPadderTestExecutionDataFile = Path.of(getClass().getResource("com.example.RightPadderTest.exec").toURI()); 60 | coveredClasses = JacocoUtil.getCoveredClasses(Files.readAllBytes(rightPadderTestExecutionDataFile)).stream() 61 | .filter(clazz -> clazz.className().startsWith("com.example")) 62 | .toList(); 63 | assertEquals(asList( 64 | new ClassNameAndJaCoCoId("com.example.RightPadder", 5424557702887177730L), 65 | new ClassNameAndJaCoCoId("com.example.RightPadderTest", 3704313255965055468L), 66 | new ClassNameAndJaCoCoId("com.example.StringUtils", 3772932961599681095L) 67 | ), coveredClasses); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /skippy-gradle/src/main/java/io/skippy/gradle/ProjectSettings.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import io.skippy.core.SkippyBuildApi; 20 | import io.skippy.core.SkippyRepository; 21 | import org.gradle.api.Project; 22 | import org.gradle.api.tasks.SourceSetContainer; 23 | 24 | import java.io.File; 25 | import java.nio.file.Path; 26 | import java.util.ArrayList; 27 | import java.util.List; 28 | import java.util.function.Consumer; 29 | 30 | /** 31 | * A sub-set of relevant {@link Project} properties that are compatible with Gradle's Configuration Cache. 32 | * 33 | * @author Florian McKee 34 | */ 35 | class ProjectSettings { 36 | 37 | final boolean buildSupportsSkippy; 38 | final List classesDirs; 39 | final SkippyPluginExtension skippyPluginExtension; 40 | final Path projectDir; 41 | final Path buildDir; 42 | 43 | /** 44 | * C'tor. 45 | * 46 | * @param projectSupportsSkippy {@code true} if the build supports Skippy, {@code false} otherwise 47 | * @param classesDirs the folders that contain compiled class files 48 | * @param skippyExtension the {@link SkippyPluginExtension} 49 | * @param projectDir the project directory (e.g., /repos/my-project) 50 | * @param buildDir the build directory (e.g., /repos/my-project/build) 51 | */ 52 | private ProjectSettings(boolean projectSupportsSkippy, List classesDirs, SkippyPluginExtension skippyExtension, Path projectDir, Path buildDir) { 53 | this.buildSupportsSkippy = projectSupportsSkippy; 54 | this.classesDirs = classesDirs; 55 | this.skippyPluginExtension = skippyExtension; 56 | this.projectDir = projectDir; 57 | this.buildDir = buildDir; 58 | } 59 | 60 | static ProjectSettings from(Project project) { 61 | var sourceSetContainer = project.getExtensions().findByType(SourceSetContainer.class); 62 | if (sourceSetContainer != null) { 63 | // new ArrayList<>() is a workaround for https://github.com/gradle/gradle/issues/26942 64 | var classesDirs = new ArrayList<>(sourceSetContainer.stream().flatMap(sourceSet -> sourceSet.getOutput().getClassesDirs().getFiles().stream()).toList()); 65 | var skippyExtension = project.getExtensions().getByType(SkippyPluginExtension.class); 66 | var projectDir = project.getProjectDir().toPath(); 67 | var buildDir = project.getLayout().getBuildDirectory().getAsFile().get().toPath(); 68 | return new ProjectSettings(sourceSetContainer != null, classesDirs, skippyExtension, projectDir, buildDir); 69 | } else { 70 | return new ProjectSettings(false, null, null, null, null); 71 | } 72 | } 73 | 74 | void ifBuildSupportsSkippy(Consumer action) { 75 | if (buildSupportsSkippy) { 76 | var skippyConfiguration = skippyPluginExtension.toSkippyConfiguration(); 77 | var skippyBuildApi = new SkippyBuildApi( 78 | skippyConfiguration, 79 | new GradleClassFileCollector(projectDir, classesDirs), 80 | SkippyRepository.getInstance( 81 | skippyConfiguration, 82 | projectDir, 83 | buildDir 84 | ) 85 | ); 86 | action.accept(skippyBuildApi); 87 | } 88 | } 89 | 90 | } -------------------------------------------------------------------------------- /skippy-extensions/skippy-repository-filesystem/src/main/java/io/skippy/extension/FileSystemBackedRepositoryExtension.java: -------------------------------------------------------------------------------- 1 | package io.skippy.extension; 2 | 3 | import io.skippy.core.SkippyRepositoryExtension; 4 | import io.skippy.core.TestImpactAnalysis; 5 | 6 | import java.io.IOException; 7 | import java.io.UncheckedIOException; 8 | import java.nio.charset.StandardCharsets; 9 | import java.nio.file.Files; 10 | import java.nio.file.Path; 11 | import java.nio.file.StandardOpenOption; 12 | import java.util.Optional; 13 | 14 | import static java.nio.file.Files.createDirectories; 15 | import static java.nio.file.Files.exists; 16 | 17 | /** 18 | * Custom {@link SkippyRepositoryExtension} that 19 | *
    20 | *
  • stores and retrieves all data in / from the .skippy folder in the user's home directory,
  • 21 | *
  • permanently retains all {@link TestImpactAnalysis} instances and
  • 22 | *
  • permanently retains all JaCoCo execution data files.
  • 23 | *
24 | * This implementation serves as simple example for how to implement of a custom {@link SkippyRepositoryExtension}. 25 | */ 26 | public class FileSystemBackedRepositoryExtension implements SkippyRepositoryExtension { 27 | 28 | private final Path storageFolder = Path.of(System.getProperty("user.home")).resolve(".skippy"); 29 | 30 | /** 31 | * Constructor used by Skippy. 32 | * 33 | * @param projectDir the project directory 34 | */ 35 | public FileSystemBackedRepositoryExtension(Path projectDir) { 36 | try { 37 | if (false == exists(storageFolder)) { 38 | createDirectories(storageFolder); 39 | } 40 | } catch (IOException e) { 41 | throw new UncheckedIOException("Could not create new instance: %s".formatted(e), e); 42 | } 43 | } 44 | 45 | @Override 46 | public Optional findTestImpactAnalysis(String id) { 47 | try { 48 | var file = storageFolder.resolve("%s.json".formatted(id)); 49 | if (false == exists(file)) { 50 | return Optional.empty(); 51 | } 52 | return Optional.of(TestImpactAnalysis.parse(Files.readString(file, StandardCharsets.UTF_8))); 53 | } catch (IOException e) { 54 | throw new UncheckedIOException("Could not create new instance: %s".formatted(e), e); 55 | } 56 | } 57 | 58 | @Override 59 | public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { 60 | try { 61 | var jsonFile = storageFolder.resolve(Path.of("%s.json".formatted(testImpactAnalysis.getId()))); 62 | Files.writeString(jsonFile, testImpactAnalysis.toJson(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 63 | } catch (IOException e) { 64 | throw new UncheckedIOException("Unable to save test impact analysis %s: %s.".formatted(testImpactAnalysis.getId(), e), e); 65 | } 66 | } 67 | 68 | @Override 69 | public Optional findJacocoExecutionData(String testExecutionId) { 70 | try { 71 | var file = storageFolder.resolve("%s.exec".formatted(testExecutionId)); 72 | if (false == exists(file)) { 73 | return Optional.empty(); 74 | } 75 | return Optional.of(Files.readAllBytes(file)); 76 | } catch (IOException e) { 77 | throw new UncheckedIOException("Unable to read JaCoCo execution data %s: %s.".formatted(testExecutionId, e), e); 78 | } 79 | } 80 | 81 | @Override 82 | public void saveJacocoExecutionData(String testExecutionId, byte[] jacocoExecutionData) { 83 | try { 84 | var file = storageFolder.resolve("%s.exec".formatted(testExecutionId)); 85 | Files.write(file, jacocoExecutionData, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); 86 | } catch (IOException e) { 87 | throw new UncheckedIOException("Unable to save JaCoCo execution data %s: %s.".formatted(testExecutionId, e), e); 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/SkippyConfigurationTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | 21 | import java.nio.file.Path; 22 | import java.util.List; 23 | import java.util.Optional; 24 | 25 | import static org.assertj.core.api.Assertions.assertThat; 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | 28 | public class SkippyConfigurationTest { 29 | 30 | @Test 31 | void testToJson1() { 32 | var configuration = new SkippyConfiguration( 33 | true, 34 | Optional.empty(), 35 | Optional.empty() 36 | ); 37 | assertThat(configuration.toJson()).isEqualToIgnoringWhitespace(""" 38 | { 39 | "coverageForSkippedTests": "true", 40 | "repositoryExtension": "io.skippy.core.DefaultRepositoryExtension", 41 | "predictionModifier": "io.skippy.core.DefaultPredictionModifier" 42 | } 43 | """); 44 | } 45 | 46 | @Test 47 | void testToJson2() { 48 | var configuration = new SkippyConfiguration( 49 | false, 50 | Optional.of("com.example.CustomRepository"), 51 | Optional.of("com.example.CustomModifier") 52 | ); 53 | assertThat(configuration.toJson()).isEqualToIgnoringWhitespace(""" 54 | { 55 | "coverageForSkippedTests": "false", 56 | "repositoryExtension": "com.example.CustomRepository", 57 | "predictionModifier": "com.example.CustomModifier" 58 | } 59 | """); 60 | } 61 | 62 | 63 | static class CustomRepository implements SkippyRepositoryExtension { 64 | 65 | public CustomRepository(Path path) { 66 | } 67 | 68 | @Override 69 | public Optional findTestImpactAnalysis(String id) { 70 | return Optional.empty(); 71 | } 72 | 73 | @Override 74 | public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { 75 | } 76 | 77 | @Override 78 | public Optional findJacocoExecutionData(String executionId) { 79 | return Optional.empty(); 80 | } 81 | 82 | @Override 83 | public void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData) { 84 | } 85 | } 86 | 87 | static class CustomModifier implements PredictionModifier { 88 | 89 | public CustomModifier() { 90 | } 91 | 92 | @Override 93 | public PredictionWithReason passThruOrModify(Class test, PredictionWithReason prediction) { 94 | return null; 95 | } 96 | } 97 | 98 | @Test 99 | void testParse() { 100 | var json = """ 101 | { 102 | "coverageForSkippedTests": "true", 103 | "repositoryExtension": "io.skippy.core.SkippyConfigurationTest$CustomRepository", 104 | "predictionModifier": "io.skippy.core.SkippyConfigurationTest$CustomModifier" 105 | } 106 | """; 107 | var configuration = SkippyConfiguration.parse(json); 108 | assertEquals(true, configuration.generateCoverageForSkippedTests()); 109 | assertEquals(CustomRepository.class, configuration.repositoryExtension(Path.of(".")).getClass()); 110 | assertEquals(CustomModifier.class, configuration.predictionModifier().getClass()); 111 | } 112 | 113 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/HashUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.objectweb.asm.ClassReader; 20 | import org.objectweb.asm.ClassVisitor; 21 | import org.objectweb.asm.ClassWriter; 22 | import org.objectweb.asm.Opcodes; 23 | 24 | import java.io.IOException; 25 | import java.io.UncheckedIOException; 26 | import java.nio.file.Path; 27 | import java.security.MessageDigest; 28 | import java.security.NoSuchAlgorithmException; 29 | 30 | import static java.nio.file.Files.newInputStream; 31 | 32 | /** 33 | * Hash functions used throughout Skippy. 34 | * 35 | * Note: None of the methods in this class are intended for security-related purposes. 36 | * 37 | * @author Florian McKee 38 | */ 39 | final class HashUtil { 40 | 41 | /** 42 | * Generates a 32-digit hexadecimal hash of the input. 43 | * 44 | * @param data the input 45 | * @return a 32-digit hexadecimal hash of the input 46 | */ 47 | static String hashWith32Digits(byte[] data) { 48 | return fullHash(data); 49 | } 50 | 51 | /** 52 | * Generates an 8-character hexadecimal hash of the input. 53 | * 54 | * @param data the input 55 | * @return ab 8-character hexadecimal hash of the input 56 | */ 57 | static String hashWith8Digits(byte[] data) { 58 | return fullHash(data).substring(24, 32); 59 | } 60 | 61 | /** 62 | * Generates a 8-digit hexadecimal hash for the {@code classfile} that is agnostic of debug information. 63 | * 64 | * If the only difference between two class files is debug information within the bytecode, their hash will be the same. 65 | *

66 | * This allows Skippy to treat certain changes like 67 | *
    68 | *
  • change in formatting and indentation,
  • 69 | *
  • updated JavaDocs and
  • 70 | *
  • addition of newlines and linebreaks
  • 71 | *
72 | * as 'no-ops'. 73 | * 74 | * @param classFile a class file 75 | * @return a 8-digit hexadecimal hash of the {@code classfile} that is agnostic of debug information 76 | */ 77 | static String debugAgnosticHash(Path classFile) { 78 | return hashWith8Digits(getBytecodeWithoutDebugInformation(classFile)); 79 | } 80 | 81 | private static String fullHash(byte[] data) { 82 | try { 83 | MessageDigest md = MessageDigest.getInstance("MD5"); 84 | md.update(data); 85 | return bytesToHex(md.digest()); 86 | } catch (NoSuchAlgorithmException e) { 87 | throw new RuntimeException("Generation of hash failed: %s".formatted(e), e); 88 | } 89 | } 90 | 91 | private static byte[] getBytecodeWithoutDebugInformation(Path classFile) { 92 | try (var inputStream = newInputStream(classFile)) { 93 | var classWriter = new ClassWriter(Opcodes.ASM9); 94 | var classVisitor = new ClassVisitor(Opcodes.ASM9, classWriter) {}; 95 | new ClassReader(inputStream).accept(classVisitor, ClassReader.SKIP_DEBUG); 96 | return classWriter.toByteArray(); 97 | } catch (IOException e) { 98 | throw new UncheckedIOException("Unable to get bytecode without debug information for class file %s: %s".formatted(classFile, e), e); 99 | } 100 | } 101 | 102 | private static String bytesToHex(byte[] bytes) { 103 | StringBuilder sb = new StringBuilder(); 104 | for (byte b : bytes) { 105 | sb.append(String.format("%02x", b)); 106 | } 107 | return sb.toString().toUpperCase(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/ClassFileTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.Test; 20 | import org.junit.jupiter.params.ParameterizedTest; 21 | import org.junit.jupiter.params.provider.CsvSource; 22 | 23 | import java.nio.file.Path; 24 | 25 | import static org.assertj.core.api.Assertions.assertThat; 26 | import static org.junit.jupiter.api.Assertions.assertEquals; 27 | 28 | public class ClassFileTest { 29 | 30 | @Test 31 | void testToJsonAllProperties() { 32 | var classFile = new ClassFile( 33 | "com.example.RightPadder", 34 | Path.of("com/example/RightPadder.class"), Path.of("build/classes/java/main"), 35 | "ZT0GoiWG8Az5TevH9/JwBg==" 36 | ); 37 | 38 | assertThat(classFile.toJson()).isEqualToIgnoringWhitespace( 39 | """ 40 | { 41 | "name": "com.example.RightPadder", 42 | "path": "com/example/RightPadder.class", 43 | "outputFolder": "build/classes/java/main", 44 | "hash": "ZT0GoiWG8Az5TevH9/JwBg==" 45 | } 46 | """); 47 | } 48 | 49 | @Test 50 | void testParse() { 51 | var classFile = ClassFile.parse(new Tokenizer( 52 | """ 53 | { 54 | "name": "com.example.RightPadder", 55 | "path": "com/example/RightPadder.class", 56 | "outputFolder": "build/classes/java/main", 57 | "hash": "ZT0GoiWG8Az5TevH9/JwBg==" 58 | } 59 | """ 60 | )); 61 | assertEquals("com.example.RightPadder", classFile.getClassName()); 62 | assertEquals(Path.of("com/example/RightPadder.class"), classFile.getPath()); 63 | assertEquals(Path.of("build/classes/java/main"), classFile.getOutputFolder()); 64 | assertEquals("ZT0GoiWG8Az5TevH9/JwBg==", classFile.getHash()); 65 | } 66 | 67 | @ParameterizedTest 68 | @CsvSource(value = { 69 | "com/example/LeftPadder.class:com.example.LeftPadder", 70 | "com/example/LeftPadderTest.class:com.example.LeftPadderTest" 71 | }, delimiter = ':') 72 | void testGetClassName(String fileName, String expectedValue) { 73 | var classFile = Path.of(fileName); 74 | var outputFolder = Path.of("build/classes/java/test"); 75 | var projectDir = Path.of("."); 76 | assertEquals(expectedValue, ClassFile.fromFileSystem(projectDir, outputFolder, projectDir.resolve(outputFolder).resolve(classFile)).getClassName()); 77 | } 78 | 79 | @ParameterizedTest 80 | @CsvSource(value = { 81 | "com/example/LeftPadder.class:com/example/LeftPadder.class", 82 | "com/example/LeftPadderTest.class:com/example/LeftPadderTest.class" 83 | }, delimiter = ':') 84 | void testGetPath(String fileName, String expectedValue) { 85 | var classFile = Path.of(fileName); 86 | var outputFolder = Path.of("build/classes/java/test"); 87 | var projectDir = Path.of("."); 88 | assertEquals(Path.of(expectedValue), ClassFile.fromFileSystem(projectDir, outputFolder, projectDir.resolve(outputFolder).resolve(classFile)).getPath()); 89 | } 90 | 91 | @ParameterizedTest 92 | @CsvSource(value = { 93 | "com/example/LeftPadder.class:build/classes/java/test", 94 | "com/example/LeftPadderTest.class:build/classes/java/test" 95 | }, delimiter = ':') 96 | void testGetOutputFolder(String fileName, String expectedValue) { 97 | var classFile = Path.of(fileName); 98 | var outputFolder = Path.of("build/classes/java/test"); 99 | var projectDir = Path.of("."); 100 | assertEquals(Path.of(expectedValue), ClassFile.fromFileSystem(projectDir, outputFolder, projectDir.resolve(outputFolder).resolve(classFile)).getOutputFolder()); 101 | } 102 | 103 | @ParameterizedTest 104 | @CsvSource(value = { 105 | "com/example/LeftPadder.class:8E994DD8", 106 | "com/example/LeftPadderTest.class:80E52EBA" 107 | }, delimiter = ':') 108 | void getHash(String fileName, String expectedValue) { 109 | var classFile = Path.of(fileName); 110 | var outputFolder = Path.of("build/classes/java/test"); 111 | var projectDir = Path.of("."); 112 | assertEquals(expectedValue, ClassFile.fromFileSystem(projectDir, outputFolder, projectDir.resolve(outputFolder).resolve(classFile)).getHash()); 113 | } 114 | 115 | 116 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/Profiler.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import io.skippy.core.SkippyConstants; 20 | import io.skippy.core.SkippyFolder; 21 | 22 | import java.io.IOException; 23 | import java.io.UncheckedIOException; 24 | import java.nio.charset.StandardCharsets; 25 | import java.nio.file.Files; 26 | import java.nio.file.Path; 27 | import java.time.Instant; 28 | import java.util.Map; 29 | import java.util.concurrent.ConcurrentHashMap; 30 | import java.util.concurrent.atomic.AtomicInteger; 31 | import java.util.concurrent.atomic.AtomicLong; 32 | import java.util.function.Supplier; 33 | 34 | import static io.skippy.core.SkippyConstants.*; 35 | import static java.lang.System.lineSeparator; 36 | import static java.nio.file.StandardOpenOption.*; 37 | import static java.util.stream.Collectors.joining; 38 | 39 | /** 40 | * Simple DIY profiler that writes the {@link SkippyConstants#PROFILING_LOG_FILE} in the skippy folder every second. 41 | *

42 | * Note: Due to the complexity of build tools (e.g., Gradle might execute a build across multiple JVMs), this profiler 43 | * might or might not generate accurate or complete profiling data. 44 | * 45 | * @author Florian McKee 46 | */ 47 | public final class Profiler { 48 | 49 | private static final boolean PROFILING_ENABLED = false; 50 | 51 | record InvocationCountAndTime(AtomicInteger invocationCount, AtomicLong time) {}; 52 | 53 | private static Map data = new ConcurrentHashMap<>(); 54 | 55 | private static Instant lastSave = Instant.now(); 56 | /** 57 | * Profiles the {@code supplier} under the given {@code label}. 58 | * 59 | * @param label a label 60 | * @param supplier a {@link Supplier} 61 | * @return the result from the {@code supplier} 62 | * @param the {@code supplier}'s return type 63 | */ 64 | public static T profile(String label, Supplier supplier) { 65 | if (lastSave.isBefore(Instant.now().minusSeconds(1))) { 66 | lastSave = Instant.now(); 67 | writeResults(SkippyFolder.get()); 68 | 69 | } 70 | if ( ! data.containsKey(label)) { 71 | data.put(label, new InvocationCountAndTime(new AtomicInteger(0), new AtomicLong(0L))); 72 | } 73 | long then = System.currentTimeMillis(); 74 | var result = supplier.get(); 75 | long now = System.currentTimeMillis(); 76 | data.get(label).invocationCount.incrementAndGet(); 77 | data.get(label).time.addAndGet(now - then); 78 | return result; 79 | } 80 | 81 | /** 82 | * Profiles the {@code runnable} under the given {@code label}. 83 | * 84 | * @param label a label 85 | * @param runnable a {@link Runnable} 86 | */ 87 | static void profile(String label, Runnable runnable) { 88 | profile(label, (Supplier) () -> { 89 | runnable.run(); 90 | return null; 91 | }); 92 | } 93 | 94 | /** 95 | * Writes the results to the profiling.log file in the skippy folder. 96 | * 97 | * @param skippyFolder the Skippy folder 98 | */ 99 | static void writeResults(Path skippyFolder) { 100 | if (PROFILING_ENABLED) { 101 | var result = "=== %s ===%s%s%s%s".formatted( 102 | Runtime.getRuntime().toString(), 103 | System.lineSeparator(), 104 | data.entrySet().stream() 105 | .map(entry -> "%s: %s call(s), %sms".formatted(entry.getKey(), entry.getValue().invocationCount, entry.getValue().time)) 106 | .sorted() 107 | .collect(joining(lineSeparator())), 108 | System.lineSeparator(), 109 | System.lineSeparator()); 110 | try { 111 | Files.writeString(skippyFolder.resolve(PROFILING_LOG_FILE), result, StandardCharsets.UTF_8, CREATE, APPEND); 112 | } catch (IOException e) { 113 | throw new UncheckedIOException(e); 114 | } 115 | } 116 | } 117 | 118 | /** 119 | * Writes the profiling results to standard out. 120 | */ 121 | static void printResults() { 122 | var result = "=== %s ===%s%s%s%s".formatted( 123 | Runtime.getRuntime().toString(), 124 | System.lineSeparator(), 125 | data.entrySet().stream() 126 | .map(entry -> "%s: %s call(s), %sms".formatted(entry.getKey(), entry.getValue().invocationCount, entry.getValue().time)) 127 | .sorted() 128 | .collect(joining(lineSeparator())), 129 | System.lineSeparator(), 130 | System.lineSeparator()); 131 | System.out.println(result); 132 | } 133 | 134 | 135 | /** 136 | * Clears all profiling data. 137 | */ 138 | public static void clear() { 139 | data.clear(); 140 | } 141 | 142 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/SkippyBuildApi.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.util.*; 20 | 21 | /** 22 | * API that is used by Skippy's Gradle and Maven plugins to remove the Skippy folder and to inform Skippy about events 23 | * like 24 | *
    25 | *
  • the start of a build,
  • 26 | *
  • the end of a build and
  • 27 | *
  • failed test cases.
  • 28 | *
29 | * 30 | * @author Florian McKee 31 | */ 32 | public final class SkippyBuildApi { 33 | 34 | private final SkippyConfiguration skippyConfiguration; 35 | private final ClassFileCollector classFileCollector; 36 | private final SkippyRepository skippyRepository; 37 | 38 | /** 39 | * C'tor. 40 | * 41 | * @param skippyConfiguration the {@link SkippyConfiguration} 42 | * @param classFileCollector the {@link ClassFileCollector} 43 | * @param skippyRepository the {@link SkippyRepository} 44 | */ 45 | public SkippyBuildApi(SkippyConfiguration skippyConfiguration, ClassFileCollector classFileCollector, SkippyRepository skippyRepository) { 46 | this.skippyConfiguration = skippyConfiguration; 47 | this.classFileCollector = classFileCollector; 48 | this.skippyRepository = skippyRepository; 49 | } 50 | 51 | /** 52 | * Resets the skippy folder. 53 | */ 54 | public void resetSkippyFolder() { 55 | skippyRepository.resetSkippyFolder(); 56 | skippyRepository.saveConfiguration(skippyConfiguration); 57 | } 58 | 59 | /** 60 | * Informs Skippy that a build has started. 61 | */ 62 | public void buildStarted() { 63 | skippyRepository.deleteLogFiles(); 64 | skippyRepository.deleteTmpFolder(); 65 | skippyRepository.saveConfiguration(skippyConfiguration); 66 | } 67 | 68 | /** 69 | * Informs Skippy that a build has finished. 70 | */ 71 | public void buildFinished() { 72 | var existingAnalysis = skippyRepository.readLatestTestImpactAnalysis(); 73 | var newAnalysis = getTestImpactAnalysis(); 74 | var mergedAnalysis = existingAnalysis.merge(newAnalysis); 75 | skippyRepository.saveTestImpactAnalysis(mergedAnalysis); 76 | if (skippyConfiguration.generateCoverageForSkippedTests()) { 77 | generateCoverageForSkippedTests(mergedAnalysis); 78 | } 79 | } 80 | 81 | private void generateCoverageForSkippedTests(TestImpactAnalysis testImpactAnalysis) { 82 | var skippedTestClassNames = skippyRepository.readPredictionsLog().stream() 83 | .filter(classNameAndPrediction -> classNameAndPrediction.prediction() == Prediction.SKIP) 84 | .map(classNameAndPrediction -> classNameAndPrediction.className()) 85 | .toList(); 86 | 87 | var skippedTests = testImpactAnalysis.getAnalyzedTests().stream() 88 | .filter(test -> skippedTestClassNames.contains(testImpactAnalysis.getClassFileContainer().getById(test.getTestClassId()).getClassName())) 89 | .toList(); 90 | 91 | List executionData = skippedTests.stream() 92 | .flatMap(skippedTest -> skippyRepository.readJacocoExecutionData(skippedTest.getExecutionId().get()).stream()) 93 | .toList(); 94 | byte[] mergeExecutionData = JacocoUtil.mergeExecutionData(executionData); 95 | 96 | skippyRepository.saveExecutionDataForSkippedTests(mergeExecutionData); 97 | } 98 | 99 | private TestImpactAnalysis getTestImpactAnalysis() { 100 | var classFileContainer = ClassFileContainer.from(classFileCollector.collect()); 101 | var testRecordings = skippyRepository.getTestRecordings(); 102 | var analyzedTests = testRecordings.stream() 103 | .map(testWithExecutionData -> getAnalyzedTests(testWithExecutionData, classFileContainer)) 104 | .toList(); 105 | return new TestImpactAnalysis(classFileContainer, analyzedTests); 106 | } 107 | 108 | private AnalyzedTest getAnalyzedTests( 109 | TestRecording testRecording, 110 | ClassFileContainer classFileContainer 111 | ) { 112 | var classFile = classFileContainer.getClassFileFor(testRecording); 113 | var executionId = skippyConfiguration.generateCoverageForSkippedTests() ? 114 | Optional.of(skippyRepository.saveJacocoExecutionData(testRecording.jacocoExecutionData())) : 115 | Optional.empty(); 116 | return AnalyzedTest.from(classFileContainer, classFile, testRecording.tags(), getCoveredClasses(testRecording, classFileContainer), executionId); 117 | } 118 | 119 | private List getCoveredClasses(TestRecording testRecording, ClassFileContainer classFileContainer) { 120 | var result = new LinkedList(); 121 | for (var coveredClass : testRecording.coveredClasses()) { 122 | result.addAll(classFileContainer.getClassFilesMatching(coveredClass)); 123 | } 124 | return result; 125 | } 126 | 127 | } -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/AnalyzedTestTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.json.JSONException; 20 | import org.junit.jupiter.api.Test; 21 | import org.skyscreamer.jsonassert.JSONAssert; 22 | import org.skyscreamer.jsonassert.JSONCompareMode; 23 | 24 | import java.util.List; 25 | import java.util.Optional; 26 | 27 | import static java.util.Arrays.asList; 28 | import static org.junit.jupiter.api.Assertions.assertEquals; 29 | 30 | public class AnalyzedTestTest { 31 | 32 | @Test 33 | void testToJsonNoCoveredClasses() throws JSONException { 34 | var analyzedTest = new AnalyzedTest(0, List.of(TestTag.PASSED), asList(), Optional.empty()); 35 | 36 | var expected = """ 37 | { 38 | "class": 0, 39 | "tags": ["PASSED"], 40 | "coveredClasses": [] 41 | } 42 | """; 43 | JSONAssert.assertEquals(expected, analyzedTest.toJson(), JSONCompareMode.LENIENT); 44 | } 45 | 46 | @Test 47 | void testToJsonOneCoveredClass() throws JSONException { 48 | var analyzedTest = new AnalyzedTest(0, List.of(TestTag.PASSED), asList(0), Optional.empty()); 49 | var expected = """ 50 | { 51 | "class": 0, 52 | "tags": ["PASSED"], 53 | "coveredClasses": [0] 54 | } 55 | """; 56 | JSONAssert.assertEquals(expected, analyzedTest.toJson(), JSONCompareMode.LENIENT); 57 | } 58 | @Test 59 | void testToJsonTwoCoveredClasses() throws JSONException { 60 | var analyzedTest = new AnalyzedTest(0, List.of(TestTag.PASSED), asList(0, 1), Optional.empty()); 61 | var expected = """ 62 | { 63 | "class": 0, 64 | "tags": ["PASSED"], 65 | "coveredClasses": [0, 1] 66 | } 67 | """; 68 | JSONAssert.assertEquals(expected, analyzedTest.toJson(), JSONCompareMode.LENIENT); 69 | } 70 | 71 | @Test 72 | void testToJsonFailedTest() throws JSONException { 73 | var analyzedTest = new AnalyzedTest(0, List.of(TestTag.FAILED), asList(), Optional.empty()); 74 | var expected = """ 75 | { 76 | "class": 0, 77 | "tags": ["FAILED"], 78 | "coveredClasses": [] 79 | } 80 | """; 81 | JSONAssert.assertEquals(expected, analyzedTest.toJson(), JSONCompareMode.LENIENT); 82 | } 83 | 84 | @Test 85 | void testParseNoCoveredClasses() { 86 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 87 | { 88 | "class": 0, 89 | "tags": ["PASSED"], 90 | "coveredClasses": [] 91 | } 92 | """)); 93 | 94 | assertEquals(0, analyzedTest.getTestClassId()); 95 | assertEquals(List.of(TestTag.PASSED), analyzedTest.getTags()); 96 | assertEquals(asList(), analyzedTest.getCoveredClassesIds()); 97 | } 98 | 99 | @Test 100 | void testParseOneCoveredClass() { 101 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 102 | { 103 | "class": 0, 104 | "tags": ["PASSED"], 105 | "coveredClasses": [0] 106 | } 107 | """)); 108 | assertEquals(asList(0), analyzedTest.getCoveredClassesIds()); 109 | } 110 | 111 | @Test 112 | void testParseTwoCoveredClasses() { 113 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 114 | { 115 | "class": 0, 116 | "tags": ["PASSED"], 117 | "coveredClasses": [0, 1] 118 | } 119 | """)); 120 | assertEquals(asList(0, 1), analyzedTest.getCoveredClassesIds()); 121 | } 122 | 123 | @Test 124 | void testParseFailedTest() { 125 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 126 | { 127 | "class": 0, 128 | "tags": ["FAILED"], 129 | "coveredClasses": [] 130 | } 131 | """)); 132 | assertEquals(List.of(TestTag.FAILED), analyzedTest.getTags()); 133 | } 134 | 135 | @Test 136 | void testParseWithoutExecutionId() { 137 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 138 | { 139 | "class": 0, 140 | "tags": ["FAILED"], 141 | "coveredClasses": [] 142 | } 143 | """)); 144 | assertEquals(Optional.empty(), analyzedTest.getExecutionId()); 145 | } 146 | 147 | @Test 148 | void testParseWithExecutionId() { 149 | var analyzedTest = AnalyzedTest.parse(new Tokenizer(""" 150 | { 151 | "class": 0, 152 | "tags": ["FAILED"], 153 | "coveredClasses": [], 154 | "executionId": "00000000000000000000000000000000" 155 | } 156 | """)); 157 | assertEquals("00000000000000000000000000000000", analyzedTest.getExecutionId().get()); 158 | } 159 | 160 | } -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/JacocoUtil.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.jacoco.core.data.ExecutionDataReader; 20 | import org.jacoco.core.data.ExecutionDataWriter; 21 | import org.jacoco.core.data.SessionInfoStore; 22 | import org.jacoco.core.tools.ExecFileLoader; 23 | 24 | import java.io.ByteArrayInputStream; 25 | import java.io.ByteArrayOutputStream; 26 | import java.io.IOException; 27 | import java.io.UncheckedIOException; 28 | import java.util.LinkedList; 29 | import java.util.List; 30 | import java.util.logging.Logger; 31 | 32 | /** 33 | * JaCoCo related utility methods. 34 | * 35 | * @author Florian McKee 36 | */ 37 | class JacocoUtil { 38 | 39 | private static final Logger LOGGER = Logger.getLogger(JacocoUtil.class.getName()); 40 | 41 | /** 42 | * Executes the {@code runnable} and swallows certain JaCoCo related exceptions to prevent build failures 43 | * when the JaCoCo agent is not running. 44 | * 45 | * @param runnable a {@link Runnable} 46 | */ 47 | static void swallowJacocoExceptions(Runnable runnable) { 48 | try { 49 | runnable.run(); 50 | } catch (NoClassDefFoundError e) { 51 | if (e.getMessage().startsWith("org/jacoco")) { 52 | LOGGER.severe("Unable to load JaCoCo class %s".formatted(e.getMessage())); 53 | LOGGER.severe(""); 54 | LOGGER.severe("Did you forget to add the Jacoco plugin to your build file?"); 55 | 56 | // suppress exception to continue the build 57 | 58 | } else { 59 | throw e; 60 | } 61 | } catch (IllegalStateException e) { 62 | if (e.getMessage().equals("JaCoCo agent not started.")) { 63 | LOGGER.severe("Jacoco agent unavailable: %s".formatted(e.getMessage())); 64 | LOGGER.severe(""); 65 | LOGGER.severe("Did you forget to add the Jacoco plugin to your build file?"); 66 | 67 | // suppress exception to continue the build 68 | 69 | } else { 70 | throw e; 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Extracts the names of the classes from JaCoCo execution data. 77 | * 78 | * @param jacocoExecutionData JaCoCo execution data 79 | * @return the names of the classes that are covered by the JaCoCo execution data 80 | */ 81 | static List getCoveredClasses(byte[] jacocoExecutionData) { 82 | try { 83 | var coveredClasses = new LinkedList(); 84 | var reader = new ExecutionDataReader(new ByteArrayInputStream(jacocoExecutionData)); 85 | reader.setSessionInfoVisitor(new SessionInfoStore()); 86 | reader.setExecutionDataVisitor(visitor -> coveredClasses.add(new ClassNameAndJaCoCoId( 87 | visitor.getName().replace("/", ".").trim(), 88 | visitor.getId() 89 | ))); 90 | reader.read(); 91 | return coveredClasses.stream().sorted().toList(); 92 | } catch (IOException e) { 93 | throw new UncheckedIOException("Unable to compute covered classes for JaCoCo execution data: %s.".formatted(e), e); 94 | } 95 | } 96 | 97 | /** 98 | * Generates an identifier that uniquely identifies the execution data (ignoring the session info data). 99 | * If two execution data arrays are equivalent except the data in the session info block, this method will 100 | * generate the same ids for both. 101 | * 102 | * @param jacocoExecutionData Jacoco execution data 103 | * @return an identifier that uniquely identifies the execution data (ignoring the session info data) 104 | */ 105 | static String getExecutionId(byte[] jacocoExecutionData) { 106 | try { 107 | var byteArrayOutputStream = new ByteArrayOutputStream(); 108 | var writer = new ExecutionDataWriter(byteArrayOutputStream); 109 | var reader = new ExecutionDataReader(new ByteArrayInputStream(jacocoExecutionData)); 110 | reader.setSessionInfoVisitor(new SessionInfoStore()); 111 | reader.setExecutionDataVisitor(executionData -> writer.visitClassExecution(executionData)); 112 | reader.read(); 113 | return HashUtil.hashWith32Digits(byteArrayOutputStream.toByteArray()); 114 | } catch (IOException e) { 115 | throw new UncheckedIOException("Unable to compute execution id from JaCoCo execution data: %s.".formatted(e), e); 116 | } 117 | } 118 | 119 | static byte[] mergeExecutionData(List executionDataList) { 120 | try { 121 | var execFileLoader = new ExecFileLoader(); 122 | for (byte[] executionData : executionDataList) { 123 | execFileLoader.load(new ByteArrayInputStream(executionData)); 124 | } 125 | var outputStream = new ByteArrayOutputStream(); 126 | execFileLoader.save(outputStream); 127 | return outputStream.toByteArray(); 128 | } catch (IOException e) { 129 | throw new RuntimeException(e); 130 | } 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /skippy-gradle/src/test/java/io/skippy/gradle/GradleClassFileCollectorTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.gradle; 18 | 19 | import org.gradle.api.file.FileCollection; 20 | import org.gradle.api.tasks.SourceSet; 21 | import org.gradle.api.tasks.SourceSetContainer; 22 | import org.gradle.api.tasks.SourceSetOutput; 23 | import org.junit.jupiter.api.Test; 24 | 25 | import java.io.File; 26 | import java.net.URISyntaxException; 27 | import java.nio.file.Paths; 28 | import java.util.Set; 29 | import java.util.stream.Stream; 30 | import java.util.stream.StreamSupport; 31 | 32 | import static java.util.Arrays.asList; 33 | import static org.assertj.core.api.Assertions.assertThat; 34 | import static org.junit.jupiter.api.Assertions.assertEquals; 35 | import static org.mockito.Mockito.mock; 36 | import static org.mockito.Mockito.when; 37 | 38 | /** 39 | * Tests for {@link GradleClassFileCollector}. 40 | * 41 | * @author Florian McKee 42 | */ 43 | public class GradleClassFileCollectorTest { 44 | 45 | @Test 46 | void testCollect() throws URISyntaxException { 47 | 48 | var sourceSetContainer = mockSourceSetContainer("main", "test"); 49 | var classesDirs = sourceSetContainer.stream().flatMap(sourceSet -> sourceSet.getOutput().getClassesDirs().getFiles().stream()).toList(); 50 | 51 | var projectDir = Paths.get(GradleClassFileCollectorTest.class.getResource("build").toURI()).getParent(); 52 | 53 | var classFileCollector = new GradleClassFileCollector(projectDir, classesDirs); 54 | 55 | var classFiles = classFileCollector.collect(); 56 | 57 | assertEquals(6, classFiles.size()); 58 | 59 | 60 | assertThat(classFiles.get(0).toJson()).isEqualToIgnoringWhitespace(""" 61 | { 62 | "name": "com.example.LeftPadder", 63 | "path": "com/example/LeftPadder.class", 64 | "outputFolder": "build/classes/java/main", 65 | "hash": "8E994DD8" 66 | } 67 | """); 68 | 69 | assertThat(classFiles.get(1).toJson()).isEqualToIgnoringWhitespace(""" 70 | { 71 | "name": "com.example.RightPadder", 72 | "path": "com/example/RightPadder.class", 73 | "outputFolder": "build/classes/java/main", 74 | "hash": "F7F27006" 75 | } 76 | """); 77 | 78 | assertThat(classFiles.get(2).toJson()).isEqualToIgnoringWhitespace(""" 79 | { 80 | "name": "com.example.StringUtils", 81 | "path": "com/example/StringUtils.class", 82 | "outputFolder": "build/classes/java/main", 83 | "hash": "ECE5D94D" 84 | } 85 | """); 86 | 87 | assertThat(classFiles.get(3).toJson()).isEqualToIgnoringWhitespace(""" 88 | { 89 | "name": "com.example.LeftPadderTest", 90 | "path": "com/example/LeftPadderTest.class", 91 | "outputFolder": "build/classes/java/test", 92 | "hash": "83A72152" 93 | } 94 | """); 95 | 96 | assertThat(classFiles.get(4).toJson()).isEqualToIgnoringWhitespace(""" 97 | { 98 | "name": "com.example.RightPadderTest", 99 | "path": "com/example/RightPadderTest.class", 100 | "outputFolder": "build/classes/java/test", 101 | "hash": "E5FB1274" 102 | } 103 | """); 104 | 105 | assertThat(classFiles.get(5).toJson()).isEqualToIgnoringWhitespace(""" 106 | { 107 | "name": "com.example.TestConstants", 108 | "path": "com/example/TestConstants.class", 109 | "outputFolder": "build/classes/java/test", 110 | "hash": "119F463C" 111 | } 112 | """); 113 | } 114 | 115 | private static SourceSetContainer mockSourceSetContainer(String... sourceSetDirectories) { 116 | var sourceSetContainer = mock(SourceSetContainer.class); 117 | var sourceSets = asList(sourceSetDirectories).stream().map(GradleClassFileCollectorTest::mockSourceSet).toList(); 118 | for (int i = 0; i < sourceSets.size(); i++) { 119 | when(sourceSetContainer.getByName(sourceSetDirectories[i])).thenReturn(sourceSets.get(i)); 120 | } 121 | when(sourceSetContainer.stream()).thenReturn(sourceSets.stream()); 122 | return sourceSetContainer; 123 | } 124 | 125 | private static SourceSet mockSourceSet(String directory) { 126 | try { 127 | File outputDirectory = Paths.get(GradleClassFileCollectorTest.class.getResource("build/classes/java/" + directory).toURI()).toFile(); 128 | var sourceSet = mock(SourceSet.class); 129 | var sourceSetOutput = mock(SourceSetOutput.class); 130 | when(sourceSet.getOutput()).thenReturn(sourceSetOutput); 131 | var classesDir = mock(FileCollection.class); 132 | when(sourceSetOutput.getClassesDirs()).thenReturn(classesDir); 133 | when(classesDir.getFiles()).thenReturn(Set.of(outputDirectory)); 134 | return sourceSet; 135 | } catch (URISyntaxException e) { 136 | throw new RuntimeException(e); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /skippy-core/src/test/java/io/skippy/core/CustomRepositoryExtensionTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import org.junit.jupiter.api.BeforeEach; 20 | import org.junit.jupiter.api.Test; 21 | 22 | import java.io.IOException; 23 | import java.net.URISyntaxException; 24 | import java.nio.charset.StandardCharsets; 25 | import java.nio.file.Files; 26 | import java.nio.file.Paths; 27 | import java.util.Optional; 28 | import java.nio.file.Path; 29 | 30 | import static java.nio.file.Files.writeString; 31 | import static org.junit.jupiter.api.Assertions.assertArrayEquals; 32 | import static org.junit.jupiter.api.Assertions.assertEquals; 33 | import static org.mockito.Mockito.*; 34 | 35 | public class CustomRepositoryExtensionTest { 36 | 37 | static SkippyRepositoryExtension extensionMock = mock(SkippyRepositoryExtension.class); 38 | 39 | static class Extension implements SkippyRepositoryExtension { 40 | 41 | public Extension(Path projectDir) { 42 | } 43 | 44 | @Override 45 | public Optional findTestImpactAnalysis(String id) { 46 | return extensionMock.findTestImpactAnalysis(id); 47 | } 48 | 49 | @Override 50 | public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { 51 | extensionMock.saveTestImpactAnalysis(testImpactAnalysis); 52 | } 53 | 54 | @Override 55 | public Optional findJacocoExecutionData(String executionId) { 56 | return extensionMock.findJacocoExecutionData(executionId); 57 | } 58 | 59 | @Override 60 | public void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData) { 61 | extensionMock.saveJacocoExecutionData(executionId, jacocoExecutionData); 62 | } 63 | } 64 | 65 | Path projectDir; 66 | SkippyRepository skippyRepository; 67 | Path skippyFolder; 68 | 69 | @BeforeEach 70 | void setUp() throws URISyntaxException { 71 | projectDir = Paths.get(getClass().getResource(".").toURI()); 72 | var config = new SkippyConfiguration( 73 | false, 74 | Optional.of(Extension.class.getName()), 75 | Optional.empty() 76 | ); 77 | skippyRepository = SkippyRepository.getInstance(config, projectDir, null); 78 | 79 | skippyRepository.resetSkippyFolder(); 80 | skippyFolder = SkippyFolder.get(projectDir); 81 | reset(extensionMock); 82 | } 83 | 84 | @Test 85 | void testFindTestImpactAnalysis() throws IOException { 86 | var testImpactAnalysis = TestImpactAnalysis.parse(""" 87 | { 88 | "classes": { 89 | "0": { 90 | "name": "com.example.FooTest", 91 | "path": "com/example/FooTest.class", 92 | "outputFolder": "build/classes/java/test", 93 | "hash": "ZT0GoiWG8Az5TevH9/JwBg==" 94 | } 95 | }, 96 | "tests": [ 97 | { 98 | "class": "0", 99 | "tags": ["PASSED"], 100 | "coveredClasses": ["0"] 101 | } 102 | ] 103 | } 104 | """); 105 | writeString(skippyFolder.resolve("LATEST"), testImpactAnalysis.getId(), StandardCharsets.UTF_8); 106 | 107 | when(extensionMock.findTestImpactAnalysis(testImpactAnalysis.getId())).thenReturn(Optional.of(testImpactAnalysis)); 108 | 109 | assertEquals(testImpactAnalysis, skippyRepository.readLatestTestImpactAnalysis()); ; 110 | } 111 | 112 | @Test 113 | void testSaveTestImpactAnalysis() { 114 | var testImpactAnalysis = TestImpactAnalysis.parse(""" 115 | { 116 | "classes": { 117 | "0": { 118 | "name": "com.example.FooTest", 119 | "path": "com/example/FooTest.class", 120 | "outputFolder": "build/classes/java/test", 121 | "hash": "ZT0GoiWG8Az5TevH9/JwBg==" 122 | } 123 | }, 124 | "tests": [ 125 | { 126 | "class": "0", 127 | "tags": ["PASSED"], 128 | "coveredClasses": ["0"] 129 | } 130 | ] 131 | } 132 | """); 133 | 134 | skippyRepository.saveTestImpactAnalysis(testImpactAnalysis); 135 | verify(extensionMock).saveTestImpactAnalysis(testImpactAnalysis); 136 | } 137 | 138 | @Test 139 | void testSaveJacocoExecutionData() throws Exception { 140 | var executionData = Files.readAllBytes(Paths.get(getClass().getResource("com.example.LeftPadderTest.exec").toURI())); 141 | skippyRepository.saveJacocoExecutionData(executionData); 142 | verify(extensionMock).saveJacocoExecutionData(JacocoUtil.getExecutionId(executionData), executionData); 143 | } 144 | 145 | @Test 146 | void testReadJacocoExecutionData() { 147 | when(extensionMock.findJacocoExecutionData("executionId")).thenReturn(Optional.of("bla".getBytes(StandardCharsets.UTF_8))); 148 | assertArrayEquals("bla".getBytes(StandardCharsets.UTF_8), skippyRepository.readJacocoExecutionData("executionId").get()); 149 | verify(extensionMock).findJacocoExecutionData("executionId"); 150 | } 151 | 152 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Skippy](https://avatars.githubusercontent.com/u/150977247?s=100&u=6f4eb4ad99fb667b1bfaf988d3d396bd892fdf16&v=4) 2 | 3 | # skippy 4 | 5 | Mono-repo for all Skippy projects. 6 | 7 | ## ⚠️ Project Status: No Longer Actively Maintained 8 | 9 | This project is no longer being actively maintained due to lack of available time. 10 | Feel free to fork, modify, and use the code as you see fit. Issues and pull requests may not receive responses. 11 | 12 | ## What is it? 13 | 14 | Skippy is a Test Impact Analysis & Predictive Test Selection framework for Java and the JVM. It cuts down on unnecessary testing 15 | and flakiness without compromising the integrity of your builds. You can run it from the command line, your favorite IDE 16 | and continuous integration server. Skippy supports Gradle, Maven, JUnit 4 and JUnit 5. 17 | 18 | Skippy is specifically designed to prevent regressions in your codebase. 19 | It supports all types of tests where the tests and the code under test run in the same JVM. 20 | It is best suited for deterministic tests, even those prone to occasional flakiness. 21 | It provides the most value for test suites that are either slow or flaky (regardless of whether the test suite contains unit, integration, or functional tests). 22 | 23 | ## What is it not? 24 | 25 | Skippy is not designed for tests that assert the overall health of a system. Don't use it for tests you want to fail 26 | in response to misbehaving services, infrastructure issues, etc. 27 | 28 | ## Highlights 29 | 30 | - Support for Gradle & Maven 31 | - Support for JUnit 4 & JUnit 5 32 | - Lightweight: Use it from the command line, your favorite IDE and CI server 33 | - Non-invasive: Use it for a single test, your entire suite and anything in-between 34 | - Free of lock-in: You can go back to a "run everything" approach at any time 35 | - Open Source under Apache 2 License 36 | 37 | ## Getting Started 38 | 39 | The best way to get started are the [Introductory Tutorials](https://www.skippy.io/tutorials). From there, a good next 40 | step is the [Reference Documentation](https://www.skippy.io/docs). 41 | 42 | ## Teaser 43 | 44 | Let's take a whirlwind tour of Skippy, Gradle & JUnit 5. The concepts are similar for Maven & JUnit 4. 45 | 46 | ### Step 1: Install Skippy 47 | 48 | ```groovy 49 | plugins { 50 | + id 'io.skippy' version '0.0.24' 51 | } 52 | 53 | dependencies { 54 | + testImplementation 'io.skippy:skippy-junit5:0.0.24' 55 | } 56 | ``` 57 | 58 | ### Step 2: Enable Predictive Test Selection 59 | 60 | Annotate the test you want to optimize with `@PredictWithSkippy`: 61 | 62 | ```java 63 | + import io.skippy.junit5.PredictWithSkippy; 64 | 65 | + @PredictWithSkippy 66 | public class FooTest { 67 | 68 | @Test 69 | public void testFoo() { 70 | assertEquals("foo", Foo.getFoo()); 71 | } 72 | 73 | } 74 | ``` 75 | 76 | ### Step 3: Skippy In Action 77 | 78 | Run the tests: 79 | ``` 80 | ./gradlew test 81 | 82 | FooTest > testFoo() PASSED 83 | BarTest > testBar() PASSED 84 | ``` 85 | 86 | Skippy performs a Test Impact Analysis every time you run a test. The result is stored in the .skippy folder: 87 | 88 | ``` 89 | ls -l .skippy 90 | 91 | test-impact-analysis.json 92 | ``` 93 | 94 | This data allows Skippy to make intelligent skip-or-execute predictions. Let's see what happens when you run the tests again: 95 | 96 | ``` 97 | ./gradlew test --rerun 98 | 99 | FooTest > testFoo() SKIPPED 100 | BarTest > testBar() SKIPPED 101 | ``` 102 | 103 | Skippy detects that nothing has changed and skips both tests. 104 | 105 | Next, introduce a bug in class `Foo`: 106 | ```java 107 | class Foo { 108 | 109 | static String getFoo() { 110 | - return "foo"; 111 | + return null; 112 | } 113 | 114 | } 115 | ``` 116 | 117 | Re-run the tests: 118 | 119 | ``` 120 | ./gradlew test 121 | 122 | FooTest > testFoo() FAILED 123 | org.opentest4j.AssertionFailedError: expected: but was: 124 | BarTest > testBar() SKIPPED 125 | ``` 126 | 127 | Skippy detects the change and executes `FooTest`. The regression is caught quickly - `BarTest` remains skipped. 128 | 129 | Fix the bug and re-run the tests: 130 | 131 | ``` 132 | ./gradlew test 133 | 134 | FooTest > testFoo() PASSED 135 | BarTest > testBar() SKIPPED 136 | ``` 137 | 138 | Skippy executes `FooTest` and updates the data in the .skippy folder. 139 | Both tests will be skipped when you run them again: 140 | 141 | ``` 142 | ./gradlew test --rerun 143 | 144 | FooTest > testFoo() SKIPPED 145 | BarTest > testBar() SKIPPED 146 | ``` 147 | 148 | ## Use Skippy In Your CI Pipeline 149 | 150 | It's safe to add the .skippy folder to version control. This will automatically enable Skippy's Predictive Test 151 | Selection when your pipeline runs. 152 | 153 | ## Contributions & Issues 154 | 155 | Contributions are always welcome! You can either 156 | - submit a pull request, 157 | - create an issue in 158 | [GitHub's issue tracker](https://github.com/skippy-io/skippy/issues) or 159 | - email [contact@skippy.io](mailto:contact@skippy.io). 160 | 161 | I would love to hear from you. 162 | 163 | ## Building Skippy Locally 164 | 165 | You need JDK 17 or upwards to build Skippy. 166 | 167 | If you want to run the entire build including tests, use `build`: 168 | 169 | ``` 170 | ./gradlew build 171 | ``` 172 | 173 | If you want to publish all jars to your local Maven repository, use `publishToMavenLocal`: 174 | 175 | ``` 176 | ./gradlew publishToMavenLocal 177 | ``` 178 | 179 | ## Projects in this repo 180 | 181 | This repo contains the following sub-projects: 182 | 183 | - [skippy-core](skippy-core/README.md): Common functionality for all libraries in this repo 184 | - [skippy-gradle](skippy-gradle/README.md): Skippy's Test Impact Analysis for Gradle 185 | - [skippy-gradle-android](skippy-gradle-android/README.md): Skippy's Test Impact Analysis for Gradle & Android 186 | - [skippy-maven](skippy-maven/README.md): Skippy's Test Impact Analysis for Maven 187 | - [skippy-junit4](skippy-junit4/README.md): Skippy's Predictive Test Selection For JUnit 4 188 | - [skippy-junit5](skippy-junit5/README.md): Skippy's Predictive Test Selection For JUnit 5 189 | -------------------------------------------------------------------------------- /skippy-core/src/main/java/io/skippy/core/SkippyConfiguration.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2023-2024 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package io.skippy.core; 18 | 19 | import java.lang.reflect.Constructor; 20 | import java.nio.file.Path; 21 | import java.util.Objects; 22 | import java.util.Optional; 23 | 24 | /** 25 | * Skippy's configuration that is used both by Skippy's build plugins and Skippy's JUnit libaries. 26 | * 27 | * @author Florian McKee 28 | */ 29 | public class SkippyConfiguration { 30 | 31 | static final SkippyConfiguration DEFAULT = new SkippyConfiguration(false, Optional.empty(), Optional.empty()); 32 | 33 | private final boolean generateCoverageForSkippedTests; 34 | private final String repositoryExtensionClass; 35 | private final String predictionModifierClass; 36 | 37 | /** 38 | * C'tor. 39 | * 40 | * @param generateCoverageForSkippedTests {@code true} to generate coverage for skipped tests, {@code false} otherwise 41 | * @param repositoryExtensionClass the fully-qualified class name of the {@link SkippyRepositoryExtension} for this build 42 | * @param predictionModifierClass the fully-qualified class name of the {@link PredictionModifier} for this build 43 | */ 44 | public SkippyConfiguration( 45 | boolean generateCoverageForSkippedTests, 46 | Optional repositoryExtensionClass, 47 | Optional predictionModifierClass 48 | ) { 49 | this.generateCoverageForSkippedTests = generateCoverageForSkippedTests; 50 | this.repositoryExtensionClass = repositoryExtensionClass.orElse(DefaultRepositoryExtension.class.getName()); 51 | this.predictionModifierClass = predictionModifierClass.orElse(DefaultPredictionModifier.class.getName()); 52 | } 53 | 54 | /** 55 | * Returns {@code true} if Skippy should generate coverage for skipped tests, {@code false} otherwise. 56 | * 57 | * @return {@code true} if Skippy should generate coverage for skipped tests, {@code false} otherwise 58 | */ 59 | boolean generateCoverageForSkippedTests() { 60 | return generateCoverageForSkippedTests; 61 | } 62 | 63 | /** 64 | * Returns the {@link SkippyRepositoryExtension} for this build. 65 | * 66 | * @return the {@link SkippyRepositoryExtension} for this build 67 | */ 68 | SkippyRepositoryExtension repositoryExtension(Path projectDir) { 69 | try { 70 | Class clazz = Class.forName(repositoryExtensionClass); 71 | Constructor constructor = clazz.getConstructor(Path.class); 72 | return (SkippyRepositoryExtension) constructor.newInstance(projectDir); 73 | } catch (Exception e) { 74 | throw new RuntimeException("Unable to create repository extension %s: %s.".formatted(repositoryExtensionClass, e), e); 75 | } 76 | } 77 | 78 | /** 79 | * Returns the {@link PredictionModifier} for this build. 80 | * 81 | * @return the {@link PredictionModifier} for this build 82 | */ 83 | PredictionModifier predictionModifier() { 84 | try { 85 | Class clazz = Class.forName(predictionModifierClass); 86 | Constructor constructor = clazz.getConstructor(); 87 | return (PredictionModifier) constructor.newInstance(); 88 | } catch (Exception e) { 89 | throw new RuntimeException("Unable to create prediction modifier %s: %s.".formatted(predictionModifierClass, e), e); 90 | } 91 | } 92 | 93 | /** 94 | * Creates a new instance from JSON. 95 | * 96 | * @param json the JSON representation of a {@link SkippyConfiguration} 97 | * @return a new instance from JSON 98 | */ 99 | static SkippyConfiguration parse(String json) { 100 | var tokenizer = new Tokenizer(json); 101 | tokenizer.skip('{'); 102 | boolean coverageForSkippedTests = false; 103 | Optional repositoryExtension = Optional.empty(); 104 | Optional predictionModifier = Optional.empty(); 105 | while (true) { 106 | var key = tokenizer.next(); 107 | tokenizer.skip(':'); 108 | switch (key) { 109 | case "coverageForSkippedTests": 110 | coverageForSkippedTests = Boolean.valueOf(tokenizer.next()); 111 | break; 112 | case "repositoryExtension": 113 | repositoryExtension = Optional.of(tokenizer.next()); 114 | break; 115 | case "predictionModifier": 116 | predictionModifier = Optional.of(tokenizer.next()); 117 | break; 118 | } 119 | tokenizer.skipIfNext(','); 120 | if (tokenizer.peek('}')) { 121 | tokenizer.skip('}'); 122 | break; 123 | } 124 | } 125 | return new SkippyConfiguration(coverageForSkippedTests, repositoryExtension, predictionModifier); 126 | } 127 | 128 | /** 129 | * Returns this instance as JSON string. 130 | * 131 | * @return the instance as JSON string 132 | */ 133 | String toJson() { 134 | return """ 135 | { 136 | "coverageForSkippedTests": "%s", 137 | "repositoryExtension": "%s", 138 | "predictionModifier": "%s" 139 | } 140 | """.formatted(generateCoverageForSkippedTests, repositoryExtensionClass, predictionModifierClass); 141 | } 142 | 143 | @Override 144 | public boolean equals(Object o) { 145 | if (this == o) return true; 146 | if (o == null || getClass() != o.getClass()) return false; 147 | SkippyConfiguration that = (SkippyConfiguration) o; 148 | return generateCoverageForSkippedTests == that.generateCoverageForSkippedTests 149 | && Objects.equals(repositoryExtensionClass, that.repositoryExtensionClass) 150 | && Objects.equals(predictionModifierClass, that.predictionModifierClass); 151 | } 152 | 153 | @Override 154 | public int hashCode() { 155 | return Objects.hash(generateCoverageForSkippedTests, repositoryExtensionClass, predictionModifierClass); 156 | } 157 | } --------------------------------------------------------------------------------