diff --git a/settings.gradle b/settings.gradle index 90f6d15..278dd21 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,3 +3,5 @@ include 'skippy-gradle' include 'skippy-maven' include 'skippy-junit4' include 'skippy-junit5' +include ':skippy-extensions:skippy-repository-filesystem' +include ':skippy-extensions:skippy-repository-regression-suite' diff --git a/skippy-core/.skippy/config.json b/skippy-core/.skippy/config.json deleted file mode 100644 index a5a3bd8..0000000 --- a/skippy-core/.skippy/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "projectDirectory": ".", - "saveExecutionData": "true" -} diff --git a/skippy-core/src/main/java/io/skippy/core/AnalyzedTest.java b/skippy-core/src/main/java/io/skippy/core/AnalyzedTest.java index 4cba950..8c17013 100644 --- a/skippy-core/src/main/java/io/skippy/core/AnalyzedTest.java +++ b/skippy-core/src/main/java/io/skippy/core/AnalyzedTest.java @@ -31,7 +31,7 @@ * "class": 0, * "result": "PASSED", * "coveredClasses": [0, 1], - * "executionId": "C57F877F6F9BF164" + * "executionId": "C57F877F...." * } * * @@ -180,16 +180,18 @@ public int compareTo(AnalyzedTest other) { } @Override - public boolean equals(Object other) { - if (other instanceof AnalyzedTest a) { - return Objects.equals(testClassId, a.testClassId); - } - return false; + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AnalyzedTest that = (AnalyzedTest) o; + return testClassId == that.testClassId && + result == that.result && + Objects.equals(coveredClassesIds, that.coveredClassesIds) && + Objects.equals(executionId, that.executionId); } @Override public int hashCode() { - return Objects.hash(testClassId); + return Objects.hash(testClassId, result, coveredClassesIds, executionId); } - } \ No newline at end of file diff --git a/skippy-core/src/main/java/io/skippy/core/ClassFile.java b/skippy-core/src/main/java/io/skippy/core/ClassFile.java index 0f38073..90040b6 100644 --- a/skippy-core/src/main/java/io/skippy/core/ClassFile.java +++ b/skippy-core/src/main/java/io/skippy/core/ClassFile.java @@ -157,24 +157,26 @@ public String toJson() { @Override public int compareTo(ClassFile other) { return comparing(ClassFile::getClassName) + .thenComparing(ClassFile::getPath) .thenComparing(ClassFile::getOutputFolder) .compare(this, other); } @Override - public boolean equals(Object other) { - if (other instanceof ClassFile c) { - return Objects.equals(getClassName() + getOutputFolder(), c.getClassName() + c.getOutputFolder()); - } - return false; + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ClassFile classFile = (ClassFile) o; + return Objects.equals(className, classFile.className) && + Objects.equals(path, classFile.path) && + Objects.equals(outputFolder, classFile.outputFolder); } @Override public int hashCode() { - return Objects.hash(getClassName(), getOutputFolder()); + return Objects.hash(className, path, outputFolder); } - boolean hasChanged() { return ! hash.equals(HashUtil.debugAgnosticHash(outputFolder.resolve(path))); } diff --git a/skippy-core/src/main/java/io/skippy/core/ClassFileContainer.java b/skippy-core/src/main/java/io/skippy/core/ClassFileContainer.java index 98c4ce7..b5976a9 100644 --- a/skippy-core/src/main/java/io/skippy/core/ClassFileContainer.java +++ b/skippy-core/src/main/java/io/skippy/core/ClassFileContainer.java @@ -183,4 +183,16 @@ ClassFileContainer merge(ClassFileContainer other) { return ClassFileContainer.from(new ArrayList<>(result)); } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ClassFileContainer that = (ClassFileContainer) o; + return Objects.equals(classFilesById, that.classFilesById); + } + + @Override + public int hashCode() { + return Objects.hash(classFilesById); + } } diff --git a/skippy-core/src/main/java/io/skippy/core/ClassNameAndPrediction.java b/skippy-core/src/main/java/io/skippy/core/ClassNameAndPrediction.java index ebe0c09..43d0bf6 100644 --- a/skippy-core/src/main/java/io/skippy/core/ClassNameAndPrediction.java +++ b/skippy-core/src/main/java/io/skippy/core/ClassNameAndPrediction.java @@ -1,3 +1,19 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.skippy.core; record ClassNameAndPrediction(String className, Prediction prediction) {} diff --git a/skippy-core/src/main/java/io/skippy/core/DefaultRepositoryExtension.java b/skippy-core/src/main/java/io/skippy/core/DefaultRepositoryExtension.java new file mode 100644 index 0000000..81bb4be --- /dev/null +++ b/skippy-core/src/main/java/io/skippy/core/DefaultRepositoryExtension.java @@ -0,0 +1,177 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.core; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Optional; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +import static java.nio.file.Files.*; + +/** + * Default {@link SkippyRepositoryExtension} implementation that + * + * It is intended for small projects that do not care about code coverage reports and thus do not need to store JaCoCo + * execution data files. However, it supports projects of any size and the storage of JaCoCo execution data + * files. This is useful for experimentation. It is not recommended to be used for large projects and / or + * projects that want to store JaCoCo execution data files since it will significantly increase the size of your Git + * repository. + *

+ * Large projects that want to store and more permanently retain {@link TestImpactAnalysis} instances and JaCoCo + * execution data files should provide a custom implementation that stores those arteficts outside the project's + * repository in storage systems + * like + * + * + * @author Florian McKee + */ +public final class DefaultRepositoryExtension implements SkippyRepositoryExtension { + + private final Path projectDir; + + /** + * Constructor that will be invoked via reflection. + * + * @param projectDir the project directory (e.g., ~/repo) + */ + public DefaultRepositoryExtension(Path projectDir) { + this.projectDir = projectDir; + } + + @Override + public Optional findTestImpactAnalysis(String id) { + try { + var jsonFile = SkippyFolder.get(projectDir).resolve(Path.of("test-impact-analysis.json")); + + if (false == exists(jsonFile)) { + return Optional.empty(); + } + var tia = TestImpactAnalysis.parse(readString(jsonFile, StandardCharsets.UTF_8)); + if ( ! id.equals(tia.getId())) { + return Optional.empty(); + } + return Optional.of(tia); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read test impact analysis: %s.".formatted(e.getMessage()), e); + } + } + + @Override + public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { + try { + var jsonFile = SkippyFolder.get(projectDir).resolve(Path.of("test-impact-analysis.json")); + Files.writeString(jsonFile, testImpactAnalysis.toJson(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + deleteObsoleteExecutionDataFiles(testImpactAnalysis); + } catch (IOException e) { + throw new UncheckedIOException("Unable to save test impact analysis %s: %s.".formatted(testImpactAnalysis.getId(), e.getMessage()), e); + } + } + + @Override + public void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData) { + try { + Files.write(SkippyFolder.get(projectDir).resolve("%s.exec".formatted(executionId)), zip(jacocoExecutionData), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("Unable to save JaCoCo execution data %s: %s.".formatted(executionId, e.getMessage()), e); + } + } + + @Override + public Optional findJacocoExecutionData(String testExecutionId) { + try { + var execFile = SkippyFolder.get(this.projectDir).resolve("%s.exec".formatted(testExecutionId)); + if (exists(execFile)) { + return Optional.of(unzip(readAllBytes(execFile))); + } + return Optional.empty(); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read JaCoCo execution data %s: %s.".formatted(testExecutionId, e.getMessage()), e); + } + } + + private void deleteObsoleteExecutionDataFiles(TestImpactAnalysis testImpactAnalysis) { + var executions = testImpactAnalysis.getExecutionIds(); + try (var directoryStream = Files.newDirectoryStream(SkippyFolder.get(projectDir), path -> path.toString().endsWith(".exec"))) { + for (var executionDataFile : directoryStream) { + if (false == executions.contains(executionDataFile.getFileName().toString().replaceAll("\\.exec", ""))) { + delete(executionDataFile); + } + } + } catch (IOException e) { + throw new UncheckedIOException("Deletion of obsolete execution data files failed: %s".formatted(e.getMessage()), e); + } + } + + private static byte[] zip(byte[] data) { + Deflater deflater = new Deflater(); + deflater.setInput(data); + deflater.finish(); + + byte[] buffer = new byte[1024]; + byte[] result = new byte[0]; + + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + byte[] tmp = new byte[result.length + count]; + System.arraycopy(result, 0, tmp, 0, result.length); + System.arraycopy(buffer, 0, tmp, result.length, count); + result = tmp; + } + deflater.end(); + return result; + } + + private static byte[] unzip(byte[] data) { + Inflater inflater = new Inflater(); + inflater.setInput(data); + + byte[] buffer = new byte[1024]; + byte[] result = new byte[0]; + + try { + while (!inflater.finished()) { + int count = inflater.inflate(buffer); + byte[] tmp = new byte[result.length + count]; + System.arraycopy(result, 0, tmp, 0, result.length); + System.arraycopy(buffer, 0, tmp, result.length, count); + result = tmp; + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + inflater.end(); + } + + return result; + } + +} \ No newline at end of file diff --git a/skippy-core/src/main/java/io/skippy/core/PredictionWithReason.java b/skippy-core/src/main/java/io/skippy/core/PredictionWithReason.java index a0053f2..479421a 100644 --- a/skippy-core/src/main/java/io/skippy/core/PredictionWithReason.java +++ b/skippy-core/src/main/java/io/skippy/core/PredictionWithReason.java @@ -16,7 +16,6 @@ package io.skippy.core; - /** * 2-tuple that contains a {@link Prediction} and the {@link Reason} why the prediction was made. * diff --git a/skippy-core/src/main/java/io/skippy/core/Reason.java b/skippy-core/src/main/java/io/skippy/core/Reason.java index 7d39dc8..e928ae0 100644 --- a/skippy-core/src/main/java/io/skippy/core/Reason.java +++ b/skippy-core/src/main/java/io/skippy/core/Reason.java @@ -26,6 +26,12 @@ record Reason(Category category, Optional details) { enum Category { + + /** + * Skippy was unable to retrieve an existing Test Impact Analysis to make a skip-or-execute decision. + */ + TEST_IMPACT_ANALYSIS_NOT_FOUND, + /** * Neither the test nor any of the covered classes have changed. */ @@ -59,7 +65,19 @@ enum Category { /** * The class file of a covered class was not found on the file system. */ - COVERED_CLASS_CLASS_FILE_NOT_FOUND + COVERED_CLASS_CLASS_FILE_NOT_FOUND, + + /** + * Coverage for skipped tests is enabled but the test has no execution id. The test needs to be re-run in order + * to capture coverage for skipped tests. + */ + MISSING_EXECUTION_ID, + + /** + * Coverage for skipped tests is enabled and the test has an execution id. However, Skippy is unable to read the + * execution data. The test needs to be re-run in order to capture coverage for skipped tests. + */ + UNABLE_TO_READ_EXECUTION_DATA } } diff --git a/skippy-core/src/main/java/io/skippy/core/SkippyApi.java b/skippy-core/src/main/java/io/skippy/core/SkippyBuildApi.java similarity index 88% rename from skippy-core/src/main/java/io/skippy/core/SkippyApi.java rename to skippy-core/src/main/java/io/skippy/core/SkippyBuildApi.java index 1f8268c..1e60139 100644 --- a/skippy-core/src/main/java/io/skippy/core/SkippyApi.java +++ b/skippy-core/src/main/java/io/skippy/core/SkippyBuildApi.java @@ -18,7 +18,6 @@ import java.util.*; - /** * API that is used by Skippy's Gradle and Maven plugins to remove the Skippy folder and to inform Skippy about events * like @@ -30,7 +29,7 @@ * * @author Florian McKee */ -public final class SkippyApi { +public final class SkippyBuildApi { private final SkippyConfiguration skippyConfiguration; private final ClassFileCollector classFileCollector; @@ -44,7 +43,7 @@ public final class SkippyApi { * @param classFileCollector the {@link ClassFileCollector} * @param skippyRepository the {@link SkippyRepository} */ - public SkippyApi(SkippyConfiguration skippyConfiguration, ClassFileCollector classFileCollector, SkippyRepository skippyRepository) { + public SkippyBuildApi(SkippyConfiguration skippyConfiguration, ClassFileCollector classFileCollector, SkippyRepository skippyRepository) { this.skippyConfiguration = skippyConfiguration; this.classFileCollector = classFileCollector; this.skippyRepository = skippyRepository; @@ -69,16 +68,16 @@ public void buildStarted() { * Informs Skippy that a build has finished. */ public void buildFinished() { - var existingAnalysis = skippyRepository.readTestImpactAnalysis().orElse(TestImpactAnalysis.NOT_FOUND); + var existingAnalysis = skippyRepository.readLatestTestImpactAnalysis(); var newAnalysis = getTestImpactAnalysis(); var mergedAnalysis = existingAnalysis.merge(newAnalysis); skippyRepository.saveTestImpactAnalysis(mergedAnalysis); - if (skippyConfiguration.saveExecutionData()) { - mergeExecutionDataForSkippedTests(mergedAnalysis); + if (skippyConfiguration.generateCoverageForSkippedTests()) { + generateCoverageForSkippedTests(mergedAnalysis); } } - private void mergeExecutionDataForSkippedTests(TestImpactAnalysis testImpactAnalysis) { + private void generateCoverageForSkippedTests(TestImpactAnalysis testImpactAnalysis) { var skippedTestClassNames = skippyRepository.readPredictionsLog().stream() .filter(classNameAndPrediction -> classNameAndPrediction.prediction() == Prediction.SKIP) .map(classNameAndPrediction -> classNameAndPrediction.className()) @@ -93,7 +92,7 @@ private void mergeExecutionDataForSkippedTests(TestImpactAnalysis testImpactAnal .toList(); byte[] mergeExecutionData = JacocoUtil.mergeExecutionData(executionData); - skippyRepository.saveMergedJacocoExecutionDataForSkippedTest(mergeExecutionData); + skippyRepository.saveExecutionDataForSkippedTests(mergeExecutionData); } /** @@ -120,7 +119,7 @@ private List getAnalyzedTests( ) { var testResult = failedTests.contains(testWithExecutionData.testClassName()) ? TestResult.FAILED : TestResult.PASSED; var ids = classFileContainer.getIdsByClassName(testWithExecutionData.testClassName()); - var executionId = skippyConfiguration.saveExecutionData() ? + var executionId = skippyConfiguration.generateCoverageForSkippedTests() ? Optional.of(skippyRepository.saveJacocoExecutionData(testWithExecutionData.jacocoExecutionData())) : Optional.empty(); return ids.stream() diff --git a/skippy-core/src/main/java/io/skippy/core/SkippyConfiguration.java b/skippy-core/src/main/java/io/skippy/core/SkippyConfiguration.java index 0c0be01..424ef5d 100644 --- a/skippy-core/src/main/java/io/skippy/core/SkippyConfiguration.java +++ b/skippy-core/src/main/java/io/skippy/core/SkippyConfiguration.java @@ -17,38 +17,51 @@ package io.skippy.core; import java.util.Objects; +import java.util.Optional; /** - * Skippy configuration. + * Skippy configuration that is used both by Skippy's build plugins and Skippy's JUnit libaries. * * @author Florian McKee */ public class SkippyConfiguration { - static final SkippyConfiguration DEFAULT = new SkippyConfiguration(false); + static final SkippyConfiguration DEFAULT = new SkippyConfiguration(false, Optional.empty()); - private final boolean saveExecutionData; + private final boolean generateCoverageForSkippedTests; + private final String repositoryClass; /** * C'tor. * - * @param saveExecutionData {@code true} to save JaCoCo execution data for individual tests, {@code false} + * @param generateCoverageForSkippedTests {@code true} to generate coverage for skipped tests, {@code false} otherwise + * @param repositoryClass the fully-qualified class name of the {@link SkippyRepositoryExtension} implementation for + * this build or {@link Optional#empty()} if Skippy should use its default implementation */ - public SkippyConfiguration(boolean saveExecutionData) { - this.saveExecutionData = saveExecutionData; + public SkippyConfiguration(boolean generateCoverageForSkippedTests, Optional repositoryClass) { + this.generateCoverageForSkippedTests = generateCoverageForSkippedTests; + this.repositoryClass = repositoryClass.orElse(DefaultRepositoryExtension.class.getName()); } /** - * Returns {@code true} if JaCoCo execution data for individual tests will be saved, {@code false} otherwise. - *

- * The purpose of this feature is to generate accurate test coverage reports despite tests being skipped. + * Returns {@code true} if Skippy should generate coverage for skipped tests, {@code false} otherwise. * - * @return {@code true} if JaCoCo execution data for individual tests will be saved, {@code false} otherwise + * @return {@code true} if Skippy should generate coverage for skipped tests, {@code false} otherwise */ - boolean saveExecutionData() { - return saveExecutionData; + boolean generateCoverageForSkippedTests() { + return generateCoverageForSkippedTests; } + /** + * Returns the fully-qualified class name of the {@link SkippyRepositoryExtension} implementation for this build. + * + * @return the fully-qualified class name of the {@link SkippyRepositoryExtension} implementation for this build + */ + String repositoryClass() { + return repositoryClass; + } + + /** * Creates a new instance from JSON. * @@ -58,13 +71,17 @@ boolean saveExecutionData() { static SkippyConfiguration parse(String json) { var tokenizer = new Tokenizer(json); tokenizer.skip('{'); - boolean executionData = false; + boolean coverageForSkippedTests = false; + Optional repositoryClass = Optional.empty(); while (true) { var key = tokenizer.next(); tokenizer.skip(':'); switch (key) { - case "saveExecutionData": - executionData = Boolean.valueOf(tokenizer.next()); + case "coverageForSkippedTests": + coverageForSkippedTests = Boolean.valueOf(tokenizer.next()); + break; + case "repositoryClass": + repositoryClass = Optional.of(tokenizer.next()); break; } tokenizer.skipIfNext(','); @@ -73,7 +90,7 @@ static SkippyConfiguration parse(String json) { break; } } - return new SkippyConfiguration(executionData); + return new SkippyConfiguration(coverageForSkippedTests, repositoryClass); } /** @@ -84,9 +101,10 @@ static SkippyConfiguration parse(String json) { String toJson() { return """ { - "saveExecutionData": "%s" + "coverageForSkippedTests": "%s", + "repositoryClass": "%s" } - """.formatted(saveExecutionData); + """.formatted(generateCoverageForSkippedTests, repositoryClass); } @Override @@ -94,11 +112,11 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; SkippyConfiguration that = (SkippyConfiguration) o; - return saveExecutionData == that.saveExecutionData; + return generateCoverageForSkippedTests == that.generateCoverageForSkippedTests && Objects.equals(repositoryClass, that.repositoryClass); } @Override public int hashCode() { - return Objects.hash(saveExecutionData); + return Objects.hash(generateCoverageForSkippedTests, repositoryClass); } } \ No newline at end of file diff --git a/skippy-core/src/main/java/io/skippy/core/SkippyRepository.java b/skippy-core/src/main/java/io/skippy/core/SkippyRepository.java index d5e7984..6bde47c 100644 --- a/skippy-core/src/main/java/io/skippy/core/SkippyRepository.java +++ b/skippy-core/src/main/java/io/skippy/core/SkippyRepository.java @@ -18,15 +18,13 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.reflect.Constructor; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.zip.Deflater; -import java.util.zip.Inflater; import static java.nio.file.Files.*; import static java.nio.file.StandardOpenOption.CREATE; @@ -42,89 +40,19 @@ * * Storage of JaCoCo execution data allows Skippy to generate test coverage reports that are equivalent to the ones * generated when running all tests. - *

- * The default implementation - *
    - *
  • stores and retrieves all data in / from the .skippy folder,
  • - *
  • only retains the latest {@link TestImpactAnalysis} and
  • - *
  • only retains the JaCoCo execution data files that are referenced by the latest {@link TestImpactAnalysis}.
  • - *
- * It is intended for small projects that do not care about code coverage reports and thus not need to store JaCoCo - * execution data files. However, it supports projects of any size and the storage of JaCoCo execution data - * files. This is useful for experimentation. It is not recommended to be used for large projects and / or - * projects that want to store JaCoCo execution data files since it will significantly increase the size of your Git - * repository. - *

- * Large projects that want to store and more permanently retain {@link TestImpactAnalysis} instances and JaCoCo - * execution data files can replace - *
    - *
  • {@link SkippyRepository#readTestImpactAnalysis()},
  • - *
  • {@link SkippyRepository#saveTestImpactAnalysis(TestImpactAnalysis)} and
  • - *
  • {@link SkippyRepository#readJacocoExecutionData(String)}
  • - *
  • {@link SkippyRepository#saveJacocoExecutionData}
  • - *
- * with custom implementations to stores and retain those artefacts outside the project's repository in storage systems - * like - *
    - *
  • databases,
  • - *
  • network file systems,
  • - *
  • blob storage like AWS S3,
  • - *
  • etc.
  • - *
- * Projects can implement and register a {@link SkippyRepositoryExtension} implementation to customize the aforementioned - * methods. - *

- * Example: - *
- * package com.example;
- *
- * public class S3SkippyRepository implements SkippyRepositoryExtension {
- *
- *    {@literal @}Override
- *     public Optional<TestImpactAnalysis> readTestImpactAnalysis() {
- *         // read from S3
- *     }
- *
- *    {@literal @}Override
- *     public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) {
- *         // save in S3
- *     }
- *
- *    {@literal @}Override
- *     public Optional<byte[]> readJacocoExecutionData(String executionId) {
- *         // read from S3
- *     }
- *
- *    {@literal @}Override
- *     public String saveJacocoExecutionData(byte[] jacocoExecutionData) {
- *         // save in S3
- *     }
- *
- * }
- * 
- * The custom implementation has to be registered with Skippy's build plugins. - *

- * Gradle example: - *
- * skippy {
- *     ...
- *     repository = 'com.example.S3SkippyRepository'
- * }
- * 
* * @author Florian McKee */ -public final class SkippyRepository implements SkippyRepositoryExtension { +public final class SkippyRepository { private final Path projectDir; private final Path buildDir; - private final SkippyConfiguration skippyConfiguration; - private final Optional extension = Optional.empty(); + private final SkippyRepositoryExtension extension; private SkippyRepository(SkippyConfiguration skippyConfiguration, Path projectDir, Path buildDir) { - this.skippyConfiguration = skippyConfiguration; this.projectDir = projectDir; this.buildDir = buildDir; + this.extension = createRepositoryExtension(skippyConfiguration, projectDir); } /** @@ -274,142 +202,78 @@ List readTemporaryJaCoCoExecutionD } /** - * Saves the merged Jacoco execution data for skipped tests as file named skipped.exec in the Skippy folder + * Saves the execution data for skipped tests as file named skipped.exec in the build directory. * - * @param mergeJacocoExecutionData the merged Jacoco execution data for skipped tests + * @param executionDataForSkippedTests Jacoco execution data for skipped tests */ - void saveMergedJacocoExecutionDataForSkippedTest(byte[] mergeJacocoExecutionData) { + void saveExecutionDataForSkippedTests(byte[] executionDataForSkippedTests) { try { - Files.write(buildDir.resolve("skippy.exec"), mergeJacocoExecutionData, CREATE, TRUNCATE_EXISTING); + Files.write(buildDir.resolve("skippy.exec"), executionDataForSkippedTests, CREATE, TRUNCATE_EXISTING); } catch (IOException e) { - throw new UncheckedIOException("Unable to save merged execution data file: %s.".formatted(e.getMessage()), e); + throw new UncheckedIOException("Unable to save execution data for skipped tests: %s.".formatted(e.getMessage()), e); } } - @Override - public Optional readTestImpactAnalysis() { - if (extension.isPresent()) { - return extension.get().readTestImpactAnalysis(); - } + TestImpactAnalysis readLatestTestImpactAnalysis() { try { - var jsonFile = SkippyFolder.get(projectDir).resolve(Path.of("test-impact-analysis.json")); - - if (false == exists(jsonFile)) { - return Optional.empty(); + var versionFile = SkippyFolder.get(projectDir).resolve(Path.of("LATEST")); + if (exists(versionFile)) { + var id = Files.readString(versionFile, StandardCharsets.UTF_8); + return extension.findTestImpactAnalysis(id).orElse(TestImpactAnalysis.NOT_FOUND); } - return Optional.of(TestImpactAnalysis.parse(Files.readString(jsonFile, StandardCharsets.UTF_8))); + return TestImpactAnalysis.NOT_FOUND; } catch (IOException e) { - throw new UncheckedIOException("Unable to read test impact analysis: %s.".formatted(e.getMessage()), e); + throw new UncheckedIOException("Unable to read latest test impact analysis: %s.".formatted(e.getMessage()), e); } } - @Override - public Optional readJacocoExecutionData(String executionId) { + private SkippyRepositoryExtension createRepositoryExtension(SkippyConfiguration skippyConfiguration, Path projectDir) { try { - var execFile = SkippyFolder.get(this.projectDir).resolve("%s.exec".formatted(executionId)); - if (exists(execFile)) { - return Optional.of(unzip(readAllBytes(execFile))); - } - return Optional.empty(); - } catch (IOException e) { - throw new UncheckedIOException("Unable to read JaCoCo execution data %s: %s.".formatted(executionId, e.getMessage()), e); + Class clazz = Class.forName(skippyConfiguration.repositoryClass()); + Constructor constructor = clazz.getConstructor(Path.class); + return (SkippyRepositoryExtension) constructor.newInstance(projectDir); + } catch (Exception e) { + throw new RuntimeException("Unable to create repository extension %s: %s.".formatted(skippyConfiguration.repositoryClass(), e.getMessage()), e); } } - @Override - public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { - if (extension.isPresent()) { - extension.get().saveTestImpactAnalysis(testImpactAnalysis); - return; - } + private List getTemporaryExecutionDataFilesForCurrentBuild() { try { - var jsonFile = SkippyFolder.get(projectDir).resolve(Path.of("test-impact-analysis.json")); - Files.writeString(jsonFile, testImpactAnalysis.toJson(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - deleteTemporaryExecutionDataFilesForCurrentBuild(); - deleteObsoleteExecutionDataFiles(testImpactAnalysis); + var result = new ArrayList(); + try (var directoryStream = Files.newDirectoryStream(SkippyFolder.get(projectDir), + file -> file.getFileName().toString().endsWith(".exec") && false == file.getFileName().toString().matches("[A-Z0-9]{32}\\.exec"))) { + for (var executionDataFile : directoryStream) { + result.add(executionDataFile); + } + } + return result; } catch (IOException e) { - throw new UncheckedIOException("Unable to save test impact analysis %s: %s.".formatted(testImpactAnalysis.getId(), e.getMessage()), e); + throw new UncheckedIOException("Unable to retrieve temporary execution data files for current build: %s".formatted(e), e); } } - /** - * Saves Jacoco execution data for usage by subsequent builds. - * - * @param jacocoExecutionData Jacoco execution data - * @return a unique identifier for the execution data (also referred to as execution id) - */ - @Override - public String saveJacocoExecutionData(byte[] jacocoExecutionData) { - if (extension.isPresent()) { - return extension.get().saveJacocoExecutionData(jacocoExecutionData); - } + void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { try { - var executionId = JacocoUtil.getExecutionId(jacocoExecutionData); - Files.write(SkippyFolder.get(projectDir).resolve("%s.exec".formatted(executionId)), zip(jacocoExecutionData), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - return executionId; + var versionFile = SkippyFolder.get(projectDir).resolve(Path.of("LATEST")); + Files.writeString(versionFile, testImpactAnalysis.getId(), StandardCharsets.UTF_8, CREATE, TRUNCATE_EXISTING); + extension.saveTestImpactAnalysis(testImpactAnalysis); + deleteTemporaryExecutionDataFilesForCurrentBuild(); } catch (IOException e) { - throw new UncheckedIOException("Unable to save JaCoCo execution data: %s.".formatted(e.getMessage()), e); + throw new UncheckedIOException("Unable to save TestImpactAnalysis %s: %s".formatted(testImpactAnalysis.getId(), e), e); } } - private static byte[] zip(byte[] data) { - Deflater deflater = new Deflater(); - deflater.setInput(data); - deflater.finish(); - - byte[] buffer = new byte[1024]; - byte[] result = new byte[0]; - - while (!deflater.finished()) { - int count = deflater.deflate(buffer); - byte[] tmp = new byte[result.length + count]; - System.arraycopy(result, 0, tmp, 0, result.length); - System.arraycopy(buffer, 0, tmp, result.length, count); - result = tmp; - } - deflater.end(); - return result; + Optional readJacocoExecutionData(String executionId) { + return extension.findJacocoExecutionData(executionId); } - private static byte[] unzip(byte[] data) { - Inflater inflater = new Inflater(); - inflater.setInput(data); - - byte[] buffer = new byte[1024]; - byte[] result = new byte[0]; - - try { - while (!inflater.finished()) { - int count = inflater.inflate(buffer); - byte[] tmp = new byte[result.length + count]; - System.arraycopy(result, 0, tmp, 0, result.length); - System.arraycopy(buffer, 0, tmp, result.length, count); - result = tmp; - } - } catch (Exception e) { - e.printStackTrace(); - } finally { - inflater.end(); - } - - return result; - } - - private void deleteObsoleteExecutionDataFiles(TestImpactAnalysis testImpactAnalysis) { - var executions = testImpactAnalysis.getExecutionIds(); - try (var directoryStream = Files.newDirectoryStream(SkippyFolder.get(projectDir), path -> path.toString().endsWith(".exec"))) { - for (var executionDataFile : directoryStream) { - if (false == executions.contains(executionDataFile.getFileName().toString().replaceAll("\\.exec", ""))) { - delete(executionDataFile); - } - } - } catch (IOException e) { - throw new UncheckedIOException("Deletion of obsolete execution data files failed: %s".formatted(e.getMessage()), e); - } + String saveJacocoExecutionData(byte[] jacocoExecutionData) { + var executionId = JacocoUtil.getExecutionId(jacocoExecutionData); + extension.saveJacocoExecutionData(executionId, jacocoExecutionData); + return executionId; } - private void deleteTemporaryExecutionDataFilesForCurrentBuild() { for (var executionDataFile : getTemporaryExecutionDataFilesForCurrentBuild()) { try { @@ -420,19 +284,4 @@ private void deleteTemporaryExecutionDataFilesForCurrentBuild() { } } - private List getTemporaryExecutionDataFilesForCurrentBuild() { - try { - var result = new ArrayList(); - try (var directoryStream = Files.newDirectoryStream(SkippyFolder.get(projectDir), - file -> file.getFileName().toString().endsWith(".exec") && false == file.getFileName().toString().matches("[A-Z0-9]{32}\\.exec"))) { - for (var executionDataFile : directoryStream) { - result.add(executionDataFile); - } - } - return result; - } catch (IOException e) { - throw new UncheckedIOException("Unable to retrieve temporary execution data files for current build: %s".formatted(e), e); - } - } - } \ No newline at end of file diff --git a/skippy-core/src/main/java/io/skippy/core/SkippyRepositoryExtension.java b/skippy-core/src/main/java/io/skippy/core/SkippyRepositoryExtension.java index 65a033d..f280f82 100644 --- a/skippy-core/src/main/java/io/skippy/core/SkippyRepositoryExtension.java +++ b/skippy-core/src/main/java/io/skippy/core/SkippyRepositoryExtension.java @@ -19,50 +19,55 @@ import java.util.Optional; /** - * Extension point that allows projects to customize Skippy's default implementation for - *
    - *
  • {@link SkippyRepository#readTestImpactAnalysis()},
  • - *
  • {@link SkippyRepository#saveTestImpactAnalysis(TestImpactAnalysis)},
  • - *
  • {@link SkippyRepository#readJacocoExecutionData(String)} and
  • - *
  • {@link SkippyRepository#saveJacocoExecutionData}
  • - *
- * - * See {@link SkippyRepository} for more information. + * Extension point that allows projects to customize retrieval and storage of {@link TestImpactAnalysis} instances and + * JaCoCo execution data files. + *

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

+ * Custom implementations must be registered using Skippy's build plugins. + *

+ * Gradle example: + *
+ * skippy {
+ *     ...
+ *     repository = 'com.example.S3SkippyRepository'
+ * }
+ * 
* * @author Florian McKee */ public interface SkippyRepositoryExtension { /** - * Returns the {@link TestImpactAnalysis} instance for the current build or an empty {@link Optional} is none - * was found. + * Retrieves a {@link TestImpactAnalysis} by {@code id}. * - * @return the {@link TestImpactAnalysis} instance for the current build or an empty {@link Optional} is none - * was found + * @param id must not be null + * @return the {@link TestImpactAnalysis} with the given {@code id} or {@link Optional#empty()} if none found */ - Optional readTestImpactAnalysis(); + Optional findTestImpactAnalysis(String id); /** - * Saves the {@link TestImpactAnalysis} generated by the current build. + * Saves a given {@link TestImpactAnalysis}. * - * @param testImpactAnalysis a {@link TestImpactAnalysis} + * @param testImpactAnalysis must not be null */ void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis); /** - * Returns the Jacoco execution data for the given {@code executionId} or an empty {@link Optional} if none was found. + * Retrieves JaCoCo execution data by {@code id}. * - * @param executionId the unique identifier for the execution data that was returned by {@link SkippyRepositoryExtension#saveJacocoExecutionData(byte[])} - * @return Jacoco execution data for the given {@code executionId} or an empty {@link Optional} if none was found + * @param executionId a unique identifier for the execution data + * @return the JaCoCo execution data with the given {@code executionId} or {@link Optional#empty()} if none found */ - Optional readJacocoExecutionData(String executionId); + Optional findJacocoExecutionData(String executionId); /** - * Saves Jacoco execution data for usage by subsequent builds. + * Saves JaCoCo execution data. * - * @param jacocoExecutionData Jacoco execution data - * @return a unique identifier for the execution data (also referred to as execution id) + * @param executionId a unique identifier for the execution data + * @param jacocoExecutionData must not be null */ - String saveJacocoExecutionData(byte[] jacocoExecutionData); + void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData); } \ No newline at end of file diff --git a/skippy-core/src/main/java/io/skippy/core/SkippyTestApi.java b/skippy-core/src/main/java/io/skippy/core/SkippyTestApi.java index 813ee6e..ef5f757 100644 --- a/skippy-core/src/main/java/io/skippy/core/SkippyTestApi.java +++ b/skippy-core/src/main/java/io/skippy/core/SkippyTestApi.java @@ -45,18 +45,20 @@ public final class SkippyTestApi { private final TestImpactAnalysis testImpactAnalysis; private final SkippyRepository skippyRepository; + private final SkippyConfiguration skippyConfiguration; private final Map predictions = new ConcurrentHashMap<>(); - private SkippyTestApi(TestImpactAnalysis testImpactAnalysis, SkippyRepository skippyRepository) { + private SkippyTestApi(TestImpactAnalysis testImpactAnalysis, SkippyConfiguration skippyConfiguration, SkippyRepository skippyRepository) { this.testImpactAnalysis = testImpactAnalysis; this.skippyRepository = skippyRepository; + this.skippyConfiguration = skippyConfiguration; } private static SkippyTestApi getInstance() { var skippyConfiguration = SkippyRepository.readConfiguration(); var skippyRepository = SkippyRepository.getInstance(skippyConfiguration); - var tia = skippyRepository.readTestImpactAnalysis().orElse(TestImpactAnalysis.NOT_FOUND); - return new SkippyTestApi(tia, skippyRepository); + var tia = skippyRepository.readLatestTestImpactAnalysis(); + return new SkippyTestApi(tia, skippyConfiguration, skippyRepository); } /** @@ -71,7 +73,7 @@ public boolean testNeedsToBeExecuted(Class test) { if (predictions.containsKey(test.getName())) { return predictions.get(test.getName()) == Prediction.EXECUTE; } - var predictionWithReason = testImpactAnalysis.predict(test.getName()); + var predictionWithReason = testImpactAnalysis.predict(test.getName(), skippyConfiguration, skippyRepository); if (predictionWithReason.reason().details().isPresent()) { Files.writeString( SkippyFolder.get().resolve(PREDICTIONS_LOG_FILE), diff --git a/skippy-core/src/main/java/io/skippy/core/TestImpactAnalysis.java b/skippy-core/src/main/java/io/skippy/core/TestImpactAnalysis.java index 5d29e44..c95c864 100644 --- a/skippy-core/src/main/java/io/skippy/core/TestImpactAnalysis.java +++ b/skippy-core/src/main/java/io/skippy/core/TestImpactAnalysis.java @@ -72,9 +72,9 @@ * "coveredClasses": [0,1] │ │ * }, ──┘ │ * { │ - * "class": 3, ───────┬──────────────────────────────────────────────────┘ - * "result": "PASSED", │ - * "coveredClasses": [2,3] + * "class": 3, ──────────────┬───────────────────────────────────────────┘ + * "result": "PASSED", │ + * "coveredClasses": [2,3] ───┘ * } * ] * } @@ -82,7 +82,7 @@ * * @author Florian McKee */ -final class TestImpactAnalysis { +public final class TestImpactAnalysis { static final TestImpactAnalysis NOT_FOUND = new TestImpactAnalysis(ClassFileContainer.from(emptyList()), emptyList()); private final ClassFileContainer classFileContainer; @@ -96,7 +96,7 @@ final class TestImpactAnalysis { */ TestImpactAnalysis(ClassFileContainer classFileContainer, List analyzedTests) { this.classFileContainer = classFileContainer; - this.analyzedTests = analyzedTests; + this.analyzedTests = analyzedTests.stream().sorted().toList(); } ClassFileContainer getClassFileContainer() { @@ -112,18 +112,28 @@ List getAnalyzedTests() { * * @return a unique identifier for this instance */ - String getId() { - return hashWith32Digits(toJson().getBytes(StandardCharsets.UTF_8)); + public String getId() { + var builder = new StringBuilder(); + builder.append(classFileContainer.toJson()); + for (var analyzedTest : analyzedTests) { + builder.append(analyzedTest.toJson()); + } + return hashWith32Digits(builder.toString().getBytes(StandardCharsets.UTF_8)); } /** * Makes a skip-or-execute prediction for the test identified by the {@code testClassName}. * * @param testClassName the test's fully-qualified class name (e.g., com.example.FooTest) + * @param configuration the {@link SkippyConfiguration}, must not be null + * @param skippyRepository the {@link SkippyRepository}, must no tbe null * @return a skip-or-execute prediction for the test identified by the {@code testClassName} */ - PredictionWithReason predict(String testClassName) { + PredictionWithReason predict(String testClassName, SkippyConfiguration configuration, SkippyRepository skippyRepository) { return Profiler.profile("TestImpactAnalysis#predict", () -> { + if (NOT_FOUND.equals(this)) { + return PredictionWithReason.execute(new Reason(TEST_IMPACT_ANALYSIS_NOT_FOUND, Optional.empty())); + } var maybeAnalyzedTest = analyzedTests.stream() .filter(test -> classFileContainer.getById(test.getTestClassId()).getClassName().equals(testClassName)) .findFirst(); @@ -144,6 +154,15 @@ PredictionWithReason predict(String testClassName) { if (testClass.hasChanged()) { return PredictionWithReason.execute(new Reason(BYTECODE_CHANGE_IN_TEST, Optional.empty())); } + if (configuration.generateCoverageForSkippedTests()) { + if (analyzedTest.getExecutionId().isEmpty()) { + return PredictionWithReason.execute(new Reason(MISSING_EXECUTION_ID, Optional.empty())); + } else { + if (skippyRepository.readJacocoExecutionData(analyzedTest.getExecutionId().get()).isEmpty()) { + return PredictionWithReason.execute(new Reason(UNABLE_TO_READ_EXECUTION_DATA, Optional.empty())); + } + } + } for (var coveredClassId : analyzedTest.getCoveredClassesIds()) { var coveredClass = classFileContainer.getById(coveredClassId); if (coveredClass.classFileNotFound()) { @@ -166,8 +185,14 @@ List getExecutionIds() { return analyzedTests.stream().flatMap(analyzedTest -> analyzedTest.getExecutionId().stream()).toList(); } - static TestImpactAnalysis parse(String string) { - return Profiler.profile("TestImpactAnalysis#parse", () -> parse(new Tokenizer(string))); + /** + * Creates a {@link TestImpactAnalysis} from a JSON string. + * + * @param jsonString the JSON representation of a {@link TestImpactAnalysis} + * @return the {@link TestImpactAnalysis} represented by the JSON string. + */ + public static TestImpactAnalysis parse(String jsonString) { + return Profiler.profile("TestImpactAnalysis#parse", () -> parse(new Tokenizer(jsonString))); } private static TestImpactAnalysis parse(Tokenizer tokenizer) { @@ -178,6 +203,9 @@ private static TestImpactAnalysis parse(Tokenizer tokenizer) { var key = tokenizer.next(); tokenizer.skip(':'); switch (key) { + case "id": + tokenizer.next(); + break; case "classes": classFileContainer = ClassFileContainer.parse(tokenizer); break; @@ -198,16 +226,18 @@ private static TestImpactAnalysis parse(Tokenizer tokenizer) { * * @return this instance as JSON string */ - String toJson() { + public String toJson() { return """ { + "id": "%s", "classes": %s, "tests": [ %s ] }""".formatted( + getId(), classFileContainer.toJson(), - analyzedTests.stream().sorted().map(c -> c.toJson()).collect(joining("," + lineSeparator()) + analyzedTests.stream().map(c -> c.toJson()).collect(joining("," + lineSeparator()) ) ); } @@ -249,4 +279,16 @@ private int remap(int id, ClassFileContainer original, ClassFileContainer merged return merged.getId(classFile); } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TestImpactAnalysis that = (TestImpactAnalysis) o; + return Objects.equals(classFileContainer, that.classFileContainer) && Objects.equals(analyzedTests, that.analyzedTests); + } + + @Override + public int hashCode() { + return Objects.hash(classFileContainer, analyzedTests); + } } \ No newline at end of file diff --git a/skippy-core/src/test/java/io/skippy/core/CustomRepositoryExtensionTest.java b/skippy-core/src/test/java/io/skippy/core/CustomRepositoryExtensionTest.java new file mode 100644 index 0000000..9a339da --- /dev/null +++ b/skippy-core/src/test/java/io/skippy/core/CustomRepositoryExtensionTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.skippy.core; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Optional; +import java.nio.file.Path; + +import static java.nio.file.Files.writeString; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +public class CustomRepositoryExtensionTest { + + static SkippyRepositoryExtension extensionMock = mock(SkippyRepositoryExtension.class); + + static class Extension implements SkippyRepositoryExtension { + + public Extension(Path projectDir) { + } + + @Override + public Optional findTestImpactAnalysis(String id) { + return extensionMock.findTestImpactAnalysis(id); + } + + @Override + public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { + extensionMock.saveTestImpactAnalysis(testImpactAnalysis); + } + + @Override + public Optional findJacocoExecutionData(String executionId) { + return extensionMock.findJacocoExecutionData(executionId); + } + + @Override + public void saveJacocoExecutionData(String executionId, byte[] jacocoExecutionData) { + extensionMock.saveJacocoExecutionData(executionId, jacocoExecutionData); + } + } + + Path projectDir; + SkippyRepository skippyRepository; + Path skippyFolder; + + @BeforeEach + void setUp() throws URISyntaxException { + projectDir = Paths.get(getClass().getResource(".").toURI()); + skippyRepository = SkippyRepository.getInstance(new SkippyConfiguration(false, Optional.of(Extension.class.getName())), projectDir, null); + skippyRepository.deleteSkippyFolder(); + skippyFolder = SkippyFolder.get(projectDir); + reset(extensionMock); + } + + @Test + void testFindTestImpactAnalysis() throws IOException { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.FooTest", + "path": "com/example/FooTest.class", + "outputFolder": "build/classes/java/test", + "hash": "ZT0GoiWG8Az5TevH9/JwBg==" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + writeString(skippyFolder.resolve("LATEST"), testImpactAnalysis.getId(), StandardCharsets.UTF_8); + + when(extensionMock.findTestImpactAnalysis(testImpactAnalysis.getId())).thenReturn(Optional.of(testImpactAnalysis)); + + assertEquals(testImpactAnalysis, skippyRepository.readLatestTestImpactAnalysis()); ; + } + + @Test + void testSaveTestImpactAnalysis() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.FooTest", + "path": "com/example/FooTest.class", + "outputFolder": "build/classes/java/test", + "hash": "ZT0GoiWG8Az5TevH9/JwBg==" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + + skippyRepository.saveTestImpactAnalysis(testImpactAnalysis); + verify(extensionMock).saveTestImpactAnalysis(testImpactAnalysis); + } + + @Test + void testSaveJacocoExecutionData() throws Exception { + var executionData = Files.readAllBytes(Paths.get(getClass().getResource("com.example.LeftPadderTest.exec").toURI())); + skippyRepository.saveJacocoExecutionData(executionData); + verify(extensionMock).saveJacocoExecutionData(JacocoUtil.getExecutionId(executionData), executionData); + } + + @Test + void testReadJacocoExecutionData() { + when(extensionMock.findJacocoExecutionData("executionId")).thenReturn(Optional.of("bla".getBytes(StandardCharsets.UTF_8))); + assertArrayEquals("bla".getBytes(StandardCharsets.UTF_8), skippyRepository.readJacocoExecutionData("executionId").get()); + verify(extensionMock).findJacocoExecutionData("executionId"); + } + +} \ No newline at end of file diff --git a/skippy-core/src/test/java/io/skippy/core/SkippyApiTest.java b/skippy-core/src/test/java/io/skippy/core/SkippyBuildApiTest.java similarity index 87% rename from skippy-core/src/test/java/io/skippy/core/SkippyApiTest.java rename to skippy-core/src/test/java/io/skippy/core/SkippyBuildApiTest.java index 7db8373..b54769d 100644 --- a/skippy-core/src/test/java/io/skippy/core/SkippyApiTest.java +++ b/skippy-core/src/test/java/io/skippy/core/SkippyBuildApiTest.java @@ -32,7 +32,7 @@ import static java.util.Arrays.asList; import static org.mockito.Mockito.*; -public final class SkippyApiTest { +public final class SkippyBuildApiTest { private ClassFileCollector classFileCollector; private Path projectDir; @@ -54,18 +54,18 @@ void setup() throws URISyntaxException { @Test void testBuildStartedSavesSkippyConfiguration() { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); buildApi.buildStarted(); - verify(skippyRepository).saveConfiguration(new SkippyConfiguration(false)); + verify(skippyRepository).saveConfiguration(new SkippyConfiguration(false, Optional.empty())); } @Test void testEmptySkippyFolderWithoutExecFiles() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.empty()); + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.NOT_FOUND); buildApi.buildStarted(); when(skippyRepository.readTemporaryJaCoCoExecutionDataForCurrentBuild()).thenReturn(asList()); @@ -90,19 +90,18 @@ void testEmptySkippyFolderWithoutExecFiles() throws JSONException { "name": "com.example.FooTest" } }, - "tests": [ - ] + "tests": [] } """; JSONAssert.assertEquals(expected, tia.toJson(), JSONCompareMode.LENIENT); } @Test - void testEmptySkippyFolderWithTwoExecFilesExecutionDataPersistenceEnabled() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(true); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + void testEmptySkippyFolderWithTwoExecFilesCoverageForSkippedTestsEnabled() throws JSONException { + var skippyConfiguration = new SkippyConfiguration(true, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.empty()); + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.NOT_FOUND); buildApi.buildStarted(); when(skippyRepository.readTemporaryJaCoCoExecutionDataForCurrentBuild()).thenReturn(asList( @@ -163,10 +162,10 @@ void testEmptySkippyFolderWithTwoExecFilesExecutionDataPersistenceEnabled() thro @Test void testEmptySkippyFolderWithTwoExecFiles() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.empty()); + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.NOT_FOUND); buildApi.buildStarted(); when(skippyRepository.readTemporaryJaCoCoExecutionDataForCurrentBuild()).thenReturn(asList( @@ -222,10 +221,10 @@ void testEmptySkippyFolderWithTwoExecFiles() throws JSONException { @Test void testEmptySkippyFolderWithTwoExecFilesAndTwoExecFiles() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.empty()); + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.NOT_FOUND); buildApi.buildStarted(); when(skippyRepository.readTemporaryJaCoCoExecutionDataForCurrentBuild()).thenReturn(asList( @@ -281,10 +280,10 @@ void testEmptySkippyFolderWithTwoExecFilesAndTwoExecFiles() throws JSONException @Test void testEmptySkippyFolderWithTwoExecFilesOneFailedTests() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.empty()); + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.NOT_FOUND); buildApi.buildStarted(); when(skippyRepository.readTemporaryJaCoCoExecutionDataForCurrentBuild()).thenReturn(asList( @@ -342,10 +341,10 @@ void testEmptySkippyFolderWithTwoExecFilesOneFailedTests() throws JSONException @Test void testExistingJsonFileNoExecFile() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.of(TestImpactAnalysis.parse(""" + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -358,7 +357,7 @@ void testExistingJsonFileNoExecFile() throws JSONException { "tests": [ ] } - """))); + """)); var tiaCaptor = ArgumentCaptor.forClass(TestImpactAnalysis.class); buildApi.buildFinished(); @@ -390,10 +389,10 @@ void testExistingJsonFileNoExecFile() throws JSONException { @Test void testExistingJsonFileUpdatedExecFile() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.of(TestImpactAnalysis.parse(""" + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -411,7 +410,7 @@ void testExistingJsonFileUpdatedExecFile() throws JSONException { } ] } - """))); + """)); buildApi.buildStarted(); @@ -458,10 +457,10 @@ void testExistingJsonFileUpdatedExecFile() throws JSONException { @Test void testExistingJsonFileUpdatedExecFileExecutionDataEnabled() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(true); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(true, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.of(TestImpactAnalysis.parse(""" + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -480,7 +479,7 @@ void testExistingJsonFileUpdatedExecFileExecutionDataEnabled() throws JSONExcept } ] } - """))); + """)); buildApi.buildStarted(); @@ -530,10 +529,10 @@ void testExistingJsonFileUpdatedExecFileExecutionDataEnabled() throws JSONExcept @Test void testExistingJsonFileNewTestFailure() throws JSONException { - var skippyConfiguration = new SkippyConfiguration(false); - var buildApi = new SkippyApi(skippyConfiguration, classFileCollector, skippyRepository); + var skippyConfiguration = new SkippyConfiguration(false, Optional.empty()); + var buildApi = new SkippyBuildApi(skippyConfiguration, classFileCollector, skippyRepository); - when(skippyRepository.readTestImpactAnalysis()).thenReturn(Optional.of(TestImpactAnalysis.parse(""" + when(skippyRepository.readLatestTestImpactAnalysis()).thenReturn(TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -574,7 +573,7 @@ void testExistingJsonFileNewTestFailure() throws JSONException { } ] } - """))); + """)); buildApi.buildStarted(); @@ -625,4 +624,5 @@ void testExistingJsonFileNewTestFailure() throws JSONException { """; JSONAssert.assertEquals(expected, tia.toJson(), JSONCompareMode.LENIENT); } + } \ No newline at end of file diff --git a/skippy-core/src/test/java/io/skippy/core/SkippyConfigurationTest.java b/skippy-core/src/test/java/io/skippy/core/SkippyConfigurationTest.java index 6ba5046..052fd89 100644 --- a/skippy-core/src/test/java/io/skippy/core/SkippyConfigurationTest.java +++ b/skippy-core/src/test/java/io/skippy/core/SkippyConfigurationTest.java @@ -16,20 +16,33 @@ package io.skippy.core; -import io.skippy.core.SkippyConfiguration; import org.junit.jupiter.api.Test; +import java.util.Optional; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; public class SkippyConfigurationTest { @Test - void testToJson() { - var configuration = new SkippyConfiguration(true); + void testToJson1() { + var configuration = new SkippyConfiguration(true, Optional.empty()); assertThat(configuration.toJson()).isEqualToIgnoringWhitespace(""" { - "saveExecutionData": "true" + "coverageForSkippedTests": "true", + "repositoryClass": "io.skippy.core.DefaultRepositoryExtension" + } + """); + } + + @Test + void testToJson2() { + var configuration = new SkippyConfiguration(true, Optional.of("com.example.CustomRepository")); + assertThat(configuration.toJson()).isEqualToIgnoringWhitespace(""" + { + "coverageForSkippedTests": "true", + "repositoryClass": "com.example.CustomRepository" } """); } @@ -38,10 +51,13 @@ void testToJson() { void testParse() { var json = """ { - "saveExecutionData": "true" + "coverageForSkippedTests": "true" } """; - assertEquals(new SkippyConfiguration(true), SkippyConfiguration.parse(json)); + var configuration = SkippyConfiguration.parse(json); + assertEquals(true, configuration.generateCoverageForSkippedTests()); + assertEquals(true, configuration.generateCoverageForSkippedTests()); + } diff --git a/skippy-core/src/test/java/io/skippy/core/SkippyRepositoryTest.java b/skippy-core/src/test/java/io/skippy/core/SkippyRepositoryTest.java index 01bd9d2..79a12b0 100644 --- a/skippy-core/src/test/java/io/skippy/core/SkippyRepositoryTest.java +++ b/skippy-core/src/test/java/io/skippy/core/SkippyRepositoryTest.java @@ -16,9 +16,6 @@ package io.skippy.core; -import io.skippy.core.SkippyFolder; -import io.skippy.core.SkippyConfiguration; -import io.skippy.core.SkippyRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,9 +25,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Optional; -import static java.nio.file.Files.exists; -import static java.nio.file.Files.readAllBytes; +import static java.nio.file.Files.*; import static java.util.Arrays.asList; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.*; @@ -45,10 +42,11 @@ public class SkippyRepositoryTest { @BeforeEach void setUp() throws URISyntaxException { projectDir = Paths.get(getClass().getResource(".").toURI()); - skippyRepository = SkippyRepository.getInstance(new SkippyConfiguration(false), projectDir, null); + skippyRepository = SkippyRepository.getInstance(new SkippyConfiguration(false, Optional.empty()), projectDir, null); skippyRepository.deleteSkippyFolder(); skippyFolder = SkippyFolder.get(projectDir); } + @Test void testDeleteSkippyFolder() { assertTrue(exists(skippyFolder)); @@ -60,11 +58,12 @@ void testDeleteSkippyFolder() { void testSaveConfiguration() throws IOException { var configFile = skippyFolder.resolve("config.json"); assertFalse(exists(skippyFolder.resolve("config.json"))); - skippyRepository.saveConfiguration(new SkippyConfiguration(true)); - var content = Files.readString(configFile, StandardCharsets.UTF_8); + skippyRepository.saveConfiguration(new SkippyConfiguration(true, Optional.empty())); + var content = readString(configFile, StandardCharsets.UTF_8); assertThat(content).isEqualToIgnoringWhitespace(""" { - "saveExecutionData": "true" + "coverageForSkippedTests": "true", + "repositoryClass": "io.skippy.core.DefaultRepositoryExtension" } """); } @@ -108,4 +107,108 @@ void testReadTemporaryJaCoCoExecutionDataForCurrentBuild() throws Exception { assertArrayEquals(readAllBytes(execFile), result.jacocoExecutionData()); } + @Test + void testSaveAndReadJaCoCoExecutionData() throws Exception { + var executionData = Files.readAllBytes(Paths.get(getClass().getResource("com.example.LeftPadderTest.exec").toURI())); + var id = skippyRepository.saveJacocoExecutionData(executionData); + assertEquals("D40016DC6B856D89EA17DB14F370D026", id); + assertArrayEquals(executionData, skippyRepository.readJacocoExecutionData(id).get()); + } + + @Test + void testSaveTestImpactAnalysis() throws IOException { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.FooTest", + "path": "com/example/FooTest.class", + "outputFolder": "build/classes/java/test", + "hash": "ZT0GoiWG8Az5TevH9/JwBg==" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + skippyRepository.saveTestImpactAnalysis(testImpactAnalysis); + + var tiaJson = skippyFolder.resolve("test-impact-analysis.json"); + assertTrue(exists(tiaJson)); + assertThat(readString(tiaJson, StandardCharsets.UTF_8)).isEqualToIgnoringWhitespace(testImpactAnalysis.toJson()); + + var latest = skippyFolder.resolve("LATEST"); + assertTrue(exists(latest)); + assertThat(readString(latest, StandardCharsets.UTF_8)).isEqualTo("D013368C0DD441D819DEA78640F4EC1A"); + } + + @Test + void readLatestTestImpactAnalysis_latest_file_not_found() { + var testImpactAnalysis = skippyRepository.readLatestTestImpactAnalysis(); + assertEquals(testImpactAnalysis, TestImpactAnalysis.NOT_FOUND); + } + + @Test + void readLatestTestImpactAnalysis_json_file_not_found() throws IOException { + writeString(skippyFolder.resolve("LATEST"), "D013368C0DD441D819DEA78640F4EC1A", StandardCharsets.UTF_8); + var testImpactAnalysis = skippyRepository.readLatestTestImpactAnalysis(); + assertEquals(testImpactAnalysis, TestImpactAnalysis.NOT_FOUND); + } + + @Test + void readLatestTestImpactAnalysis_json_file_does_not_match_version_in_latest() throws IOException { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.FooTest", + "path": "com/example/FooTest.class", + "outputFolder": "build/classes/java/test", + "hash": "ZT0GoiWG8Az5TevH9/JwBg==" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + writeString(skippyFolder.resolve("LATEST"), "00000000000000000000000000000000", StandardCharsets.UTF_8); + writeString(skippyFolder.resolve("test-impact-analysis.json"), testImpactAnalysis.toJson(), StandardCharsets.UTF_8); + assertEquals(TestImpactAnalysis.NOT_FOUND, skippyRepository.readLatestTestImpactAnalysis()); + } + @Test + void readLatestTestImpactAnalysis_json_file_does_match_version_in_latest() throws IOException { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.FooTest", + "path": "com/example/FooTest.class", + "outputFolder": "build/classes/java/test", + "hash": "ZT0GoiWG8Az5TevH9/JwBg==" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + writeString(skippyFolder.resolve("LATEST"), testImpactAnalysis.getId(), StandardCharsets.UTF_8); + writeString(skippyFolder.resolve("test-impact-analysis.json"), testImpactAnalysis.toJson(), StandardCharsets.UTF_8); + assertEquals(testImpactAnalysis, skippyRepository.readLatestTestImpactAnalysis()); + } + } \ No newline at end of file diff --git a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisMergeTest.java b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisMergeTest.java index 83910c5..c5012c4 100644 --- a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisMergeTest.java +++ b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisMergeTest.java @@ -27,27 +27,22 @@ public class TestImpactAnalysisMergeTest { void testEmptyBaseline() { var baseline = TestImpactAnalysis.parse(""" { - "classes": { - }, - "tests": [ - ] + "classes": {}, + "tests": [] } """); var newAnalysis = TestImpactAnalysis.parse(""" { - "classes": { - }, - "tests": [ - ] + "classes": {}, + "tests": [] } """); var mergedAnalysis = baseline.merge(newAnalysis); assertThat(mergedAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { - "classes": { - }, - "tests": [ - ] + "id": "F8D85DB143EC3F06FAD5D0E0C730E1E9", + "classes": {}, + "tests": [] } """); } @@ -56,10 +51,8 @@ void testEmptyBaseline() { void testEmptyBaselineNewClassAndNewTest() { var baseline = TestImpactAnalysis.parse(""" { - "classes": { - }, - "tests": [ - ] + "classes": {}, + "tests": [] } """); var newAnalysis = TestImpactAnalysis.parse(""" @@ -84,6 +77,7 @@ void testEmptyBaselineNewClassAndNewTest() { var mergedAnalysis = baseline.merge(newAnalysis); assertThat(mergedAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "2DF824AB81F8E6FB957D5E16DA86B981", "classes": { "0": { "name": "com.example.FooTest", @@ -146,6 +140,7 @@ void testAdditionalClassAndTest() { var mergedAnalysis = baseline.merge(newAnalysis); assertThat(mergedAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "11354F8AC8619304F48F36FB5D4458C3", "classes": { "0": { "name": "com.example.BarTest", @@ -219,6 +214,7 @@ void testUpdatedClassAndTest() { var mergedAnalysis = baseline.merge(newAnalysis); assertThat(mergedAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "316A0F66B5E6E1C3993C60575ECA82FD", "classes": { "0": { "name": "com.example.FooTest", @@ -283,6 +279,7 @@ void testMergeWithExecutionId() { var mergedAnalysis = baseline.merge(newAnalysis); assertThat(mergedAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "021C7D48FCEF65129C3F898D3DA393D3", "classes": { "0": { "name": "com.example.FooTest", diff --git a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisParsePerformanceTest.java b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisParsePerformanceTest.java index 6d85b01..baad82a 100644 --- a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisParsePerformanceTest.java +++ b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisParsePerformanceTest.java @@ -16,8 +16,6 @@ package io.skippy.core; -import io.skippy.core.TestImpactAnalysis; -import io.skippy.core.Profiler; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; diff --git a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisPredictTest.java b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisPredictTest.java index ce022a4..56bf689 100644 --- a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisPredictTest.java +++ b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisPredictTest.java @@ -16,19 +16,35 @@ package io.skippy.core; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.Optional; + import static io.skippy.core.Prediction.EXECUTE; import static io.skippy.core.Prediction.SKIP; import static io.skippy.core.Reason.Category.*; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class TestImpactAnalysisPredictTest { - @Test - void testPredictNoChange() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Nested + class ScenariosWithCoverageForSkippedTestsDisabled { + + @Test + void testNoTestImpactAnalysisFound() { + var testImpactAnalysis = TestImpactAnalysis.NOT_FOUND; + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(TEST_IMPACT_ANALYSIS_NOT_FOUND, predictionWithReason.reason().category()); + } + + @Test + void testPredictNoChange() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -53,29 +69,35 @@ void testPredictNoChange() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(SKIP, predictionWithReason.prediction()); - assertEquals(NO_CHANGE, predictionWithReason.reason().category()); - } + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(SKIP, predictionWithReason.prediction()); + assertEquals(NO_CHANGE, predictionWithReason.reason().category()); + } - @Test - void testPredictUnknownTest() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" - { - "classes": { - }, - "tests": [ - ] - } - """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(NO_DATA_FOUND_FOR_TEST, predictionWithReason.reason().category()); - } + @Test + void testPredictUnknownTest() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.LeftPadder", + "path": "io/skippy/core/LeftPadder.class", + "outputFolder": "src/test/resources", + "hash": "8E994DD8" + } + }, + "tests": [ + ] + } + """); + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(NO_DATA_FOUND_FOR_TEST, predictionWithReason.reason().category()); + } - @Test - void testPredictBytecodeChangeInTest() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Test + void testPredictBytecodeChangeInTest() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -100,14 +122,14 @@ void testPredictBytecodeChangeInTest() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(BYTECODE_CHANGE_IN_TEST, predictionWithReason.reason().category()); - } + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(BYTECODE_CHANGE_IN_TEST, predictionWithReason.reason().category()); + } - @Test - void testPredictBytecodeChangeInCoveredClass() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Test + void testPredictBytecodeChangeInCoveredClass() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -132,15 +154,15 @@ void testPredictBytecodeChangeInCoveredClass() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(BYTECODE_CHANGE_IN_COVERED_CLASS, predictionWithReason.reason().category()); - assertEquals("com.example.LeftPadder", predictionWithReason.reason().details().get()); - } + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(BYTECODE_CHANGE_IN_COVERED_CLASS, predictionWithReason.reason().category()); + assertEquals("com.example.LeftPadder", predictionWithReason.reason().details().get()); + } - @Test - void testPredictFailedTest() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Test + void testPredictFailedTest() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -165,14 +187,14 @@ void testPredictFailedTest() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(TEST_FAILED_PREVIOUSLY, predictionWithReason.reason().category()); - } + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(TEST_FAILED_PREVIOUSLY, predictionWithReason.reason().category()); + } - @Test - void testPredictTestClassFileNotFound() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Test + void testPredictTestClassFileNotFound() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -197,15 +219,15 @@ void testPredictTestClassFileNotFound() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(TEST_CLASS_CLASS_FILE_NOT_FOUND, predictionWithReason.reason().category()); - assertEquals("io/skippy/core/LeftPadderTest$Bla.class", predictionWithReason.reason().details().get()); - } + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(TEST_CLASS_CLASS_FILE_NOT_FOUND, predictionWithReason.reason().category()); + assertEquals("io/skippy/core/LeftPadderTest$Bla.class", predictionWithReason.reason().details().get()); + } - @Test - void testPredictCoveredClassClassFileNotFound() { - var testImpactAnalysis = TestImpactAnalysis.parse(""" + @Test + void testPredictCoveredClassClassFileNotFound() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" { "classes": { "0": { @@ -230,10 +252,104 @@ void testPredictCoveredClassClassFileNotFound() { ] } """); - var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest"); - assertEquals(EXECUTE, predictionWithReason.prediction()); - assertEquals(COVERED_CLASS_CLASS_FILE_NOT_FOUND, predictionWithReason.reason().category()); - assertEquals("io/skippy/core/LeftPadder$Bla.class", predictionWithReason.reason().details().get()); + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", SkippyConfiguration.DEFAULT, SkippyRepository.getInstance(SkippyConfiguration.DEFAULT)); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(COVERED_CLASS_CLASS_FILE_NOT_FOUND, predictionWithReason.reason().category()); + assertEquals("io/skippy/core/LeftPadder$Bla.class", predictionWithReason.reason().details().get()); + } + + } + + @Nested + class ScenariosWithCoverageForSkippedTestsEnabled { + + @Test + void testHappyPath() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.LeftPadderTest", + "path": "io/skippy/core/LeftPadderTest.class", + "outputFolder": "src/test/resources", + "hash": "83A72152" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"], + "executionId": "00000000000000000000000000000000" + } + ] + } + """); + var configuration = new SkippyConfiguration(true, Optional.empty()); + var repository = mock(SkippyRepository.class); + when(repository.readJacocoExecutionData("00000000000000000000000000000000")).thenReturn(Optional.of(new byte[]{})); + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", configuration, repository); + assertEquals(SKIP, predictionWithReason.prediction()); + } + + + @Test + void testMissingExecutionId() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.LeftPadderTest", + "path": "io/skippy/core/LeftPadderTest.class", + "outputFolder": "src/test/resources", + "hash": "83A72152" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"] + } + ] + } + """); + var configuration = new SkippyConfiguration(true, Optional.empty()); + var repository = SkippyRepository.getInstance(configuration); + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", configuration, repository); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(MISSING_EXECUTION_ID, predictionWithReason.reason().category()); + } + + @Test + void testUnableToReadExecutionData() { + var testImpactAnalysis = TestImpactAnalysis.parse(""" + { + "classes": { + "0": { + "name": "com.example.LeftPadderTest", + "path": "io/skippy/core/LeftPadderTest.class", + "outputFolder": "src/test/resources", + "hash": "83A72152" + } + }, + "tests": [ + { + "class": "0", + "result": "PASSED", + "coveredClasses": ["0"], + "executionId": "00000000000000000000000000000000" + } + ] + } + """); + var configuration = new SkippyConfiguration(true, Optional.empty()); + var repository = SkippyRepository.getInstance(configuration); + var predictionWithReason = testImpactAnalysis.predict("com.example.LeftPadderTest", configuration, repository); + assertEquals(EXECUTE, predictionWithReason.prediction()); + assertEquals(UNABLE_TO_READ_EXECUTION_DATA, predictionWithReason.reason().category()); + } + } } \ No newline at end of file diff --git a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisTest.java b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisTest.java index 8034089..2911d96 100644 --- a/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisTest.java +++ b/skippy-core/src/test/java/io/skippy/core/TestImpactAnalysisTest.java @@ -35,10 +35,9 @@ void testToJsonNoClassesNoTests() { var testImpactAnalysis = new TestImpactAnalysis(ClassFileContainer.from(emptyList()), emptyList()); assertThat(testImpactAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { - "classes": { - }, - "tests": [ - ] + "id": "F8D85DB143EC3F06FAD5D0E0C730E1E9", + "classes": {}, + "tests": [] } """); } @@ -57,6 +56,7 @@ void testToJsonOneTestOneClass() { ); assertThat(testImpactAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "D013368C0DD441D819DEA78640F4EC1A", "classes": { "0": { "name": "com.example.FooTest", @@ -111,6 +111,7 @@ void testToJsonTwoTestsFourClasses() { ); assertThat(testImpactAnalysis.toJson()).isEqualToIgnoringWhitespace(""" { + "id": "4473271405D844F7E9F969057E7FA479", "classes": { "0": { "name": "com.example.Class1", @@ -157,10 +158,9 @@ void testToJsonTwoTestsFourClasses() { void testParseNoClassesNoTests() { var testImpactAnalysis = TestImpactAnalysis.parse(""" { - "classes": { - }, - "tests": [ - ] + "id": "00000000000000000000000000000000", + "classes": {}, + "tests": [] } """); @@ -172,6 +172,7 @@ void testParseNoClassesNoTests() { void testParseOneTestOneClass() { var testImpactAnalysis = TestImpactAnalysis.parse(""" { + "id": "00000000000000000000000000000000", "classes": { "0": { "name": "com.example.FooTest", @@ -205,6 +206,7 @@ void testParseOneTestOneClass() { void testParseTwoTestsFourClasses() { var testImpactAnalysis = TestImpactAnalysis.parse(""" { + "id": "00000000000000000000000000000000", "classes": { "0": { "name": "com.example.Class1", diff --git a/skippy-extensions/skippy-repository-filesystem/README.md b/skippy-extensions/skippy-repository-filesystem/README.md new file mode 100644 index 0000000..5578574 --- /dev/null +++ b/skippy-extensions/skippy-repository-filesystem/README.md @@ -0,0 +1,3 @@ +# skippy-repository-filesystem + +Sample repository extension that stores all data in the filesystem \ No newline at end of file diff --git a/skippy-extensions/skippy-repository-filesystem/build.gradle b/skippy-extensions/skippy-repository-filesystem/build.gradle new file mode 100644 index 0000000..39c48f9 --- /dev/null +++ b/skippy-extensions/skippy-repository-filesystem/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'java-library' + id 'io.skippy.ossrh-publish' +} + +ossrhPublish { + title = 'skippy-repository-filesystem' + description = 'Sample repository extension that stores all data in the filesystem' +} + +dependencies { + implementation project(':skippy-core') + testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 + testImplementation 'org.assertj:assertj-core:' + versions.assertj + testImplementation 'org.mockito:mockito-core:' + versions.mockito +} + +test { + testLogging { + events "passed", "skipped", "failed" + showStandardStreams true + exceptionFormat 'FULL' + } + useJUnitPlatform() +} \ No newline at end of file diff --git a/skippy-extensions/skippy-repository-filesystem/src/main/java/io/skippy/extension/FileSystemBackedRepositoryExtension.java b/skippy-extensions/skippy-repository-filesystem/src/main/java/io/skippy/extension/FileSystemBackedRepositoryExtension.java new file mode 100644 index 0000000..a2057d9 --- /dev/null +++ b/skippy-extensions/skippy-repository-filesystem/src/main/java/io/skippy/extension/FileSystemBackedRepositoryExtension.java @@ -0,0 +1,90 @@ +package io.skippy.extension; + +import io.skippy.core.SkippyRepositoryExtension; +import io.skippy.core.TestImpactAnalysis; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Optional; + +import static java.nio.file.Files.createDirectories; +import static java.nio.file.Files.exists; + +/** + * Custom {@link SkippyRepositoryExtension} that + *
    + *
  • stores and retrieves all data in / from the .skippy folder in the user's home directory,
  • + *
  • permanently retains all {@link TestImpactAnalysis} instances and
  • + *
  • permanently retains all JaCoCo execution data files.
  • + *
+ * This implementation serves as simple example for how to implement of a custom {@link SkippyRepositoryExtension}. + */ +public class FileSystemBackedRepositoryExtension implements SkippyRepositoryExtension { + + private final Path storageFolder = Path.of(System.getProperty("user.home")).resolve(".skippy"); + + /** + * Constructor used by Skippy. + * + * @param projectDir the project directory + */ + public FileSystemBackedRepositoryExtension(Path projectDir) { + try { + if (false == exists(storageFolder)) { + createDirectories(storageFolder); + } + } catch (IOException e) { + throw new UncheckedIOException("Could not create new instance: %s".formatted(e.getMessage()), e); + } + } + + @Override + public Optional findTestImpactAnalysis(String id) { + try { + var file = storageFolder.resolve("%s.json".formatted(id)); + if (false == exists(file)) { + return Optional.empty(); + } + return Optional.of(TestImpactAnalysis.parse(Files.readString(file, StandardCharsets.UTF_8))); + } catch (IOException e) { + throw new UncheckedIOException("Could not create new instance: %s".formatted(e.getMessage()), e); + } + } + + @Override + public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { + try { + var jsonFile = storageFolder.resolve(Path.of("%s.json".formatted(testImpactAnalysis.getId()))); + Files.writeString(jsonFile, testImpactAnalysis.toJson(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("Unable to save test impact analysis %s: %s.".formatted(testImpactAnalysis.getId(), e.getMessage()), e); + } + } + + @Override + public Optional findJacocoExecutionData(String testExecutionId) { + try { + var file = storageFolder.resolve("%s.exec".formatted(testExecutionId)); + if (false == exists(file)) { + return Optional.empty(); + } + return Optional.of(Files.readAllBytes(file)); + } catch (IOException e) { + throw new UncheckedIOException("Unable to read JaCoCo execution data %s: %s.".formatted(testExecutionId, e.getMessage()), e); + } + } + + @Override + public void saveJacocoExecutionData(String testExecutionId, byte[] jacocoExecutionData) { + try { + var file = storageFolder.resolve("%s.exec".formatted(testExecutionId)); + Files.write(file, jacocoExecutionData, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("Unable to save JaCoCo execution data %s: %s.".formatted(testExecutionId, e.getMessage()), e); + } + } +} diff --git a/skippy-extensions/skippy-repository-regression-suite/README.md b/skippy-extensions/skippy-repository-regression-suite/README.md new file mode 100644 index 0000000..f52046c --- /dev/null +++ b/skippy-extensions/skippy-repository-regression-suite/README.md @@ -0,0 +1,3 @@ +# skippy-repository-regression-suite + +Internal repository extension that is used by the tests in skippy-regression-suite. \ No newline at end of file diff --git a/skippy-extensions/skippy-repository-regression-suite/build.gradle b/skippy-extensions/skippy-repository-regression-suite/build.gradle new file mode 100644 index 0000000..a836d42 --- /dev/null +++ b/skippy-extensions/skippy-repository-regression-suite/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'java-library' + id 'io.skippy.ossrh-publish' +} + +ossrhPublish { + title = 'skippy-repository-regression-suite' + description = 'Repository extension that is used by the tests in skippy-regression-suite' +} + +dependencies { + implementation project(':skippy-core') + testImplementation "org.junit.jupiter:junit-jupiter-api:" + versions.junit5 + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:" + versions.junit5 + testImplementation 'org.assertj:assertj-core:' + versions.assertj + testImplementation 'org.mockito:mockito-core:' + versions.mockito +} + +test { + testLogging { + events "passed", "skipped", "failed" + showStandardStreams true + exceptionFormat 'FULL' + } + useJUnitPlatform() +} \ No newline at end of file diff --git a/skippy-extensions/skippy-repository-regression-suite/src/main/java/io/skippy/extension/RegressionSuiteRepositoryExtension.java b/skippy-extensions/skippy-repository-regression-suite/src/main/java/io/skippy/extension/RegressionSuiteRepositoryExtension.java new file mode 100644 index 0000000..8e57b67 --- /dev/null +++ b/skippy-extensions/skippy-repository-regression-suite/src/main/java/io/skippy/extension/RegressionSuiteRepositoryExtension.java @@ -0,0 +1,54 @@ +package io.skippy.extension; + +import io.skippy.core.DefaultRepositoryExtension; +import io.skippy.core.SkippyRepositoryExtension; +import io.skippy.core.TestImpactAnalysis; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Optional; + +import static java.nio.file.Files.createDirectories; +import static java.nio.file.Files.writeString; + +/** + * Custom {@link SkippyRepositoryExtension} that is internally used by the tests in skippy-regression-suite. + */ +public class RegressionSuiteRepositoryExtension implements SkippyRepositoryExtension { + + private final SkippyRepositoryExtension defaultExtension; + + /** + * Constructor that will be invoked via reflection. + * + * @param projectDir the project directory (e.g., ~/repo) + * @throws IOException an {@link IOException} + */ + public RegressionSuiteRepositoryExtension(Path projectDir) throws IOException { + defaultExtension = new DefaultRepositoryExtension(projectDir); + // write a marker that will be checked by the regression tests + createDirectories(projectDir.resolve(".skippy")); + writeString(projectDir.resolve(".skippy").resolve("REPOSITORY"), getClass().getName(), StandardCharsets.UTF_8); + } + + @Override + public Optional findTestImpactAnalysis(String id) { + return defaultExtension.findTestImpactAnalysis(id); + } + + @Override + public void saveTestImpactAnalysis(TestImpactAnalysis testImpactAnalysis) { + defaultExtension.saveTestImpactAnalysis(testImpactAnalysis); + } + + @Override + public Optional findJacocoExecutionData(String testExecutionId) { + return defaultExtension.findJacocoExecutionData(testExecutionId); + } + + @Override + public void saveJacocoExecutionData(String testExecutionId, byte[] jacocoExecutionData) { + defaultExtension.saveJacocoExecutionData(testExecutionId, jacocoExecutionData); + } +} diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyGradleUtils.java b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyGradleUtils.java index d7796bb..58e700d 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyGradleUtils.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyGradleUtils.java @@ -16,7 +16,7 @@ package io.skippy.gradle; -import io.skippy.core.SkippyApi; +import io.skippy.core.SkippyBuildApi; import io.skippy.core.SkippyRepository; import org.gradle.api.Project; import org.gradle.api.tasks.SourceSetContainer; @@ -25,13 +25,13 @@ final class SkippyGradleUtils { - static void ifBuildSupportsSkippy(Project project, Consumer action) { + static void ifBuildSupportsSkippy(Project project, Consumer action) { var sourceSetContainer = project.getExtensions().findByType(SourceSetContainer.class); if (sourceSetContainer != null) { var skippyExtension = project.getExtensions().getByType(SkippyPluginExtension.class); var skippyConfiguration = skippyExtension.toSkippyConfiguration(); var projectDir = project.getProjectDir().toPath(); - var skippyBuildApi = new SkippyApi( + var skippyBuildApi = new SkippyBuildApi( skippyConfiguration, new GradleClassFileCollector(projectDir, sourceSetContainer), SkippyRepository.getInstance( diff --git a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java index 16bc4e1..00a7d0e 100644 --- a/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java +++ b/skippy-gradle/src/main/java/io/skippy/gradle/SkippyPluginExtension.java @@ -19,6 +19,8 @@ import io.skippy.core.SkippyConfiguration; import org.gradle.api.provider.Property; +import java.util.Optional; + /** * Extension that allows configuration of Skippy in Gradle's build file: *
@@ -33,11 +35,18 @@
 public interface SkippyPluginExtension  {
 
     /**
-     * Returns the property to enable / disable capture of per-test JaCoCo execution data.
+     * Returns the property to enable / disable coverage generation for skipped tests.
+     *
+     * @return the property to enable / disable coverage generation for skipped tests
+     */
+    Property getCoverageForSkippedTests();
+
+    /**
+     * Returns the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension}.
      *
-     * @return the property to enable / disable capture of per-test JaCoCo execution data
+     * @return the property to register a custom {@link io.skippy.core.SkippyRepositoryExtension}
      */
-    Property getSaveExecutionData();
+    Property getRepository();
 
     /**
      * Converts the extension data into a {@link SkippyConfiguration}
@@ -45,6 +54,6 @@ public interface SkippyPluginExtension  {
      * @return a {@link SkippyConfiguration} derived from the extension data
      */
     default SkippyConfiguration toSkippyConfiguration() {
-        return new SkippyConfiguration(getSaveExecutionData().getOrElse(false));
+        return new SkippyConfiguration(getCoverageForSkippedTests().getOrElse(false), Optional.ofNullable(getRepository().getOrNull()));
     }
 }
\ No newline at end of file
diff --git a/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildFinishedMojo.java b/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildFinishedMojo.java
index b40dd42..f3db5d1 100644
--- a/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildFinishedMojo.java
+++ b/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildFinishedMojo.java
@@ -16,7 +16,7 @@
 
 package io.skippy.maven;
 
-import io.skippy.core.SkippyApi;
+import io.skippy.core.SkippyBuildApi;
 import io.skippy.core.SkippyConfiguration;
 import io.skippy.core.SkippyRepository;
 import org.apache.maven.execution.MavenSession;
@@ -28,6 +28,7 @@
 import org.apache.maven.project.MavenProject;
 
 import java.nio.file.Path;
+import java.util.Optional;
 
 /**
  * Mojo that informs Skippy that the parts of the build that are relevant for Skippy (e.g., compilation and test
@@ -39,8 +40,11 @@ public class SkippyBuildFinishedMojo extends AbstractMojo {
     @Parameter(defaultValue = "${project}", required = true, readonly = true)
     MavenProject project;
 
-    @Parameter(defaultValue = "false", property = "saveExecutionData", required = false)
-    private boolean saveExecutionData;
+    @Parameter(defaultValue = "false", property = "coverageForSkippedTests", required = false)
+    private boolean coverageForSkippedTests;
+
+    @Parameter(defaultValue = "false", property = "repository", required = false)
+    private String repository;
 
     @Component
     private MavenSession session;
@@ -48,8 +52,8 @@ public class SkippyBuildFinishedMojo extends AbstractMojo {
     @Override
     public void execute() {
         var projectDir = project.getBasedir().toPath();
-        var skippyConfiguration = new SkippyConfiguration(saveExecutionData);
-        var skippyApi = new SkippyApi(
+        var skippyConfiguration = new SkippyConfiguration(coverageForSkippedTests, Optional.ofNullable(repository));
+        var skippyApi = new SkippyBuildApi(
                 skippyConfiguration,
                 new MavenClassFileCollector(project),
                 SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent()))
diff --git a/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildStartedMojo.java b/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildStartedMojo.java
index b6aefa0..3788728 100644
--- a/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildStartedMojo.java
+++ b/skippy-maven/src/main/java/io/skippy/maven/SkippyBuildStartedMojo.java
@@ -16,7 +16,7 @@
 
 package io.skippy.maven;
 
-import io.skippy.core.SkippyApi;
+import io.skippy.core.SkippyBuildApi;
 import io.skippy.core.SkippyConfiguration;
 import io.skippy.core.SkippyRepository;
 import org.apache.maven.execution.MavenSession;
@@ -28,6 +28,7 @@
 import org.apache.maven.project.MavenProject;
 
 import java.nio.file.Path;
+import java.util.Optional;
 
 /**
  * Mojo that informs Skippy that a build has started.
@@ -38,8 +39,11 @@ public class SkippyBuildStartedMojo extends AbstractMojo {
     @Parameter(defaultValue = "${project}", required = true, readonly = true)
     MavenProject project;
 
-    @Parameter(defaultValue = "false", property = "saveExecutionData", required = false)
-    private boolean saveExecutionData;
+    @Parameter(defaultValue = "false", property = "coverageForSkippedTests", required = false)
+    private boolean coverageForSkippedTests;
+
+    @Parameter(defaultValue = "false", property = "repository", required = false)
+    private String repository;
 
     @Component
     private MavenSession session;
@@ -47,8 +51,8 @@ public class SkippyBuildStartedMojo extends AbstractMojo {
     @Override
     public void execute() {
         var projectDir = project.getBasedir().toPath();
-        var skippyConfiguration = new SkippyConfiguration(saveExecutionData);
-        var skippyApi = new SkippyApi(
+        var skippyConfiguration = new SkippyConfiguration(coverageForSkippedTests, Optional.ofNullable(repository));
+        var skippyApi = new SkippyBuildApi(
                 skippyConfiguration,
                 new MavenClassFileCollector(project),
                 SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent()))
diff --git a/skippy-maven/src/main/java/io/skippy/maven/SkippyCleanMojo.java b/skippy-maven/src/main/java/io/skippy/maven/SkippyCleanMojo.java
index 9ba8445..7712d29 100644
--- a/skippy-maven/src/main/java/io/skippy/maven/SkippyCleanMojo.java
+++ b/skippy-maven/src/main/java/io/skippy/maven/SkippyCleanMojo.java
@@ -16,7 +16,7 @@
 
 package io.skippy.maven;
 
-import io.skippy.core.SkippyApi;
+import io.skippy.core.SkippyBuildApi;
 import io.skippy.core.SkippyConfiguration;
 import io.skippy.core.SkippyRepository;
 import org.apache.maven.execution.MavenSession;
@@ -28,6 +28,7 @@
 import org.apache.maven.project.MavenProject;
 
 import java.nio.file.Path;
+import java.util.Optional;
 
 /**
  * Clears the skippy folder.
@@ -42,8 +43,11 @@ public class SkippyCleanMojo extends AbstractMojo {
     @Parameter(defaultValue = "${project}", required = true, readonly = true)
     MavenProject project;
 
-    @Parameter(defaultValue = "false", property = "saveExecutionData", required = false)
-    private boolean saveExecutionData;
+    @Parameter(defaultValue = "false", property = "coverageForSkippedTests", required = false)
+    private boolean coverageForSkippedTests;
+
+    @Parameter(defaultValue = "false", property = "repository", required = false)
+    private String repository;
 
     @Component
     private MavenSession session;
@@ -51,8 +55,8 @@ public class SkippyCleanMojo extends AbstractMojo {
     @Override
     public void execute() {
         var projectDir = project.getBasedir().toPath();
-        var skippyConfiguration = new SkippyConfiguration(saveExecutionData);
-        var skippyApi = new SkippyApi(
+        var skippyConfiguration = new SkippyConfiguration(coverageForSkippedTests, Optional.ofNullable(repository));
+        var skippyApi = new SkippyBuildApi(
                 skippyConfiguration,
                 new MavenClassFileCollector(project),
                 SkippyRepository.getInstance(skippyConfiguration, projectDir, projectDir.resolve(Path.of(project.getBuild().getOutputDirectory()).getParent()))
diff --git a/skippy-maven/src/main/resources/META-INF/maven/plugin.xml b/skippy-maven/src/main/resources/META-INF/maven/plugin.xml
index c013382..e38cda6 100644
--- a/skippy-maven/src/main/resources/META-INF/maven/plugin.xml
+++ b/skippy-maven/src/main/resources/META-INF/maven/plugin.xml
@@ -43,11 +43,18 @@
           
         
         
-          saveExecutionData
+          coverageForSkippedTests
           boolean
           false
           true
-          enables / disables capture of per-test JaCoCo execution data
+          enables / disables generation of test coverage for skipped tests
+        
+        
+          repository
+          string
+          false
+          true
+          fully-qualified class name of a custom io.skippy.core.SkippyRepositoryExtension
         
       
       
@@ -85,11 +92,18 @@
           
         
         
-          saveExecutionData
+          coverageForSkippedTests
           boolean
           false
           true
-          enables / disables capture of per-test JaCoCo execution data
+          enables / disables generation of test coverage for skipped tests
+        
+        
+          repository
+          string
+          false
+          true
+          fully-qualified class name of a custom io.skippy.core.SkippyRepositoryExtension
         
       
       
@@ -127,11 +141,18 @@
           
         
         
-          saveExecutionData
+          coverageForSkippedTests
           boolean
           false
           true
-          enables / disables capture of per-test JaCoCo execution data
+          enables / disables generation of test coverage for skipped tests
+        
+        
+          repository
+          string
+          false
+          true
+          fully-qualified class name of a custom io.skippy.core.SkippyRepositoryExtension