Skip to content

Commit

Permalink
issues/150: Allow projects to customize SkippyRepository's default be…
Browse files Browse the repository at this point in the history
…havior (#151)
  • Loading branch information
fmck3516 authored Apr 10, 2024
1 parent bea9451 commit f30d59b
Show file tree
Hide file tree
Showing 35 changed files with 1,211 additions and 450 deletions.
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 0 additions & 4 deletions skippy-core/.skippy/config.json

This file was deleted.

18 changes: 10 additions & 8 deletions skippy-core/src/main/java/io/skippy/core/AnalyzedTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* "class": 0,
* "result": "PASSED",
* "coveredClasses": [0, 1],
* "executionId": "C57F877F6F9BF164"
* "executionId": "C57F877F...."
* }
* </pre>
*
Expand Down Expand Up @@ -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);
}

}
16 changes: 9 additions & 7 deletions skippy-core/src/main/java/io/skippy/core/ClassFile.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand Down
12 changes: 12 additions & 0 deletions skippy-core/src/main/java/io/skippy/core/ClassFileContainer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
@@ -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
* <ul>
* <li>stores and retrieves all data in / from the .skippy folder,</li>
* <li>only retains the latest {@link TestImpactAnalysis} and </li>
* <li>only retains the JaCoCo execution data files that are referenced by the latest {@link TestImpactAnalysis}.</li>
* </ul>
* 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.
* <br /><br />
* 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
* <ul>
* <li>databases,</li>
* <li>network file systems,</li>
* <li>blob storage like AWS S3,</li>
* <li>etc.</li>
* </ul>
*
* @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<TestImpactAnalysis> 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<byte[]> 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

package io.skippy.core;


/**
* 2-tuple that contains a {@link Prediction} and the {@link Reason} why the prediction was made.
*
Expand Down
20 changes: 19 additions & 1 deletion skippy-core/src/main/java/io/skippy/core/Reason.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
record Reason(Category category, Optional<String> 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.
*/
Expand Down Expand Up @@ -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
}

}
Loading

0 comments on commit f30d59b

Please sign in to comment.