diff --git a/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResult.java b/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResult.java new file mode 100644 index 00000000..42c1c721 --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResult.java @@ -0,0 +1,58 @@ +package org.mockito.release.internal.comparison.file; + +import org.json.simple.Jsonable; +import org.json.simple.Jsoner; + +import java.io.File; +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +/** + * Holds the compare result of two directories. The result consists of files which are only available in dirA, files + * which are only available in dirB and files which are available in both directories and their content is different. + */ +public class CompareResult implements Jsonable { + + private static final String JSON_FORMAT = "{ \"onlyA\": %s, \"onlyB\": %s, " + + "\"both\": %s }"; + + private List onlyA; + private List onlyB; + private List bothButDifferent; + + public void setOnlyA(List file) { + this.onlyA = file; + } + + public void setOnlyB(List file) { + this.onlyB = file; + } + + public void setBothButDifferent(List file) { + this.bothButDifferent = file; + } + + @Override + public String toJson() { + return String.format(JSON_FORMAT, + Jsoner.serialize(toStringList(onlyA)), + Jsoner.serialize(toStringList(onlyB)), + Jsoner.serialize(toStringList(bothButDifferent))); + } + + private List toStringList(List files) { + List ret = new ArrayList(files.size()); + for (File file : files) { + ret.add(file.getPath()); + } + return ret; + } + + @Override + public void toJson(Writer writable) throws IOException { + writable.append(toJson()); + } + +} diff --git a/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializer.java b/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializer.java new file mode 100644 index 00000000..46c9d37f --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializer.java @@ -0,0 +1,52 @@ +package org.mockito.release.internal.comparison.file; + +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.json.simple.DeserializationException; +import org.json.simple.JsonObject; +import org.json.simple.Jsoner; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Json serializer for {@link CompareResult}s. + */ +public class CompareResultSerializer { + + private static final Logger LOG = Logging.getLogger(CompareResultSerializer.class); + + public String serialize(CompareResult compareResult) { + String json = Jsoner.serialize(compareResult); + LOG.info("Serialize compare result to: {}", json); + return json; + } + + public CompareResult deserialize(String json) { + CompareResult compareResult = new CompareResult(); + try { + LOG.info("Deserialize compare result from: {}", json); + JsonObject jsonObject = (JsonObject) Jsoner.deserialize(json); + Collection onlyA = jsonObject.getCollection("onlyA"); + Collection onlyB = jsonObject.getCollection("onlyB"); + Collection both = jsonObject.getCollection("both"); + compareResult.setOnlyA(toFileList(onlyA)); + compareResult.setOnlyB(toFileList(onlyB)); + compareResult.setBothButDifferent(toFileList(both)); + } catch (DeserializationException e) { + throw new RuntimeException("Can't deserialize JSON: " + json, e); + } + return compareResult; + } + + private List toFileList(Collection onlyA) { + List fileList = new ArrayList(onlyA.size()); + for (String filePath : onlyA) { + fileList.add(new File(filePath)); + } + return fileList; + } + +} diff --git a/src/main/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProvider.java b/src/main/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProvider.java new file mode 100644 index 00000000..7a0324ee --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProvider.java @@ -0,0 +1,86 @@ +package org.mockito.release.internal.comparison.file; + +import org.mockito.release.internal.util.Md5; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +public class FileDifferenceProvider { + + public CompareResult getDifference(File dirA, File dirB) { + List dirAFiles = getFilesResursive(dirA.listFiles()); + Collections.sort(dirAFiles); + List dirBFiles = getFilesResursive(dirB.listFiles()); + Collections.sort(dirBFiles); + + List onlyA = new ArrayList(); + List onlyB = new ArrayList(); + List bothButDifferent = new ArrayList(); + + int i = 0; + int j = 0; + + while (dirAFiles.size() > i && dirBFiles.size() > j) { + String dirASubPath = dirAFiles.get(i).getPath().substring(dirA.getPath().length()); + String dirBSubPath = dirBFiles.get(j).getPath().substring(dirB.getPath().length()); + int compareResult = dirASubPath.compareTo(dirBSubPath); + + if (compareResult < 0) { + onlyA.add(dirAFiles.get(i)); + i++; + } else if (compareResult > 0) { + onlyB.add(dirBFiles.get(j)); + j++; + } else { + String md5A = Md5.calculate(dirAFiles.get(i)); + String md5B = Md5.calculate(dirBFiles.get(j)); + boolean sameMd5 = md5A.equals(md5B); + + if (dirAFiles.get(i).length() == dirBFiles.get(j).length() && sameMd5) { + // nothing to do, both files are available + } else { + bothButDifferent.add(dirAFiles.get(i)); + bothButDifferent.add(dirBFiles.get(j)); + } + i++; + j++; + } + } + + if (dirAFiles.size() == i && dirBFiles.size() > j) { + while (j < dirBFiles.size()) { + onlyB.add(dirBFiles.get(j)); + j++; + } + } + + if (dirBFiles.size() == j && dirAFiles.size() > i) { + while (i < dirAFiles.size()) { + onlyA.add(dirAFiles.get(i)); + i++; + } + } + + CompareResult result = new CompareResult(); + result.setOnlyA(onlyA); + result.setOnlyB(onlyB); + result.setBothButDifferent(bothButDifferent); + + return result; + } + + + private List getFilesResursive(File[] files) { + List filesRecursive = new ArrayList(); + for (File file : files) { + filesRecursive.add(file); + if (file.isDirectory()) { + filesRecursive.addAll(getFilesResursive(file.listFiles())); + } + } + return filesRecursive; + } +} diff --git a/src/main/groovy/org/mockito/release/internal/gradle/BuildABTestingPlugin.java b/src/main/groovy/org/mockito/release/internal/gradle/BuildABTestingPlugin.java new file mode 100644 index 00000000..841a4c17 --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/gradle/BuildABTestingPlugin.java @@ -0,0 +1,133 @@ +package org.mockito.release.internal.gradle; + +import org.gradle.api.Action; +import org.gradle.api.DefaultTask; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.file.CopySpec; +import org.gradle.api.tasks.*; +import org.mockito.release.exec.DefaultProcessRunner; + +import java.io.File; + +/** + * See rationale and design at: https://github.com/mockito/mockito-release-tools/issues/113 + */ +public class BuildABTestingPlugin implements Plugin { + + public void apply(final Project project) { + //Step 1. Clone the repo for A/B testing + CloneGitRepositoryTask clone = project.getTasks().create("cloneGitRepository", CloneGitRepositoryTask.class); + clone.setRepository("https://github.com/mockito/mockito"); + clone.setTargetDir(new File(project.getBuildDir(), "abTesting/mockito/pristine")); + + //Step 2. Run A test + RunTestTask runA = project.getTasks().create("runA", RunTestTask.class); + runA.dependsOn(clone); + runA.setSourceDir(clone.getTargetDir()); + runA.setWorkDir(new File(project.getBuildDir(), "abTesting/mockito/testA-" + System.currentTimeMillis())); + // using assemble task for now in order to get different results + runA.commandLine("./gradlew", "assemble"); + runA.setOutputDir(new File(runA.getWorkDir(), "build")); + + //Step 3. Run B test + RunTestTask runB = project.getTasks().create("runB", RunTestTask.class); + runB.dependsOn(clone); + runB.setSourceDir(clone.getTargetDir()); + runB.setWorkDir(new File(project.getBuildDir(), "abTesting/mockito/testB-" + System.currentTimeMillis())); + runB.commandLine("./gradlew", "build"); + runB.setOutputDir(new File(runB.getWorkDir(), "build")); + + //Step 4. Compare test outcomes + CompareDirectoriesTask compare = project.getTasks().create("compareAB", CompareDirectoriesTask.class); + compare.dependsOn(runA, runB); + compare.setDirA(runA.getOutputDir()); + compare.setDirB(runB.getOutputDir()); + compare.setResultsFile(new File(project.getBuildDir(), "abTesting/mockito/results.json")); //or JSON :) + + //Step 5. Analyze comparison results + AnalyzeComparisonResultsTask analyze = project.getTasks().create("analyzeAB", AnalyzeComparisonResultsTask.class); + analyze.dependsOn(compare); + analyze.setComparisonResultsFile(compare.getResultsFile()); + } + + /** + * BELOW task types are only empty shells. + * They act as suggestions for workflow / API design. + */ + + public static class AnalyzeComparisonResultsTask extends DefaultTask { + + private File comparisonResultsFile; + + @InputFile + public void setComparisonResultsFile(File comparisonResultsFile) { + this.comparisonResultsFile = comparisonResultsFile; + } + + @TaskAction + public void analyze() { + + } + } + + /** + * Gradle task to run a given command line. + * Note: not yet migrated to RunTestReleaseTask because RunTestReleaseTask requires two separate checkout tasks + * which might take some time (e.g. if we are using a repo like mockito and the internet connection is not that fast. + */ + public static class RunTestTask extends DefaultTask { + + private File sourceDir; + private File workDir; + private File outputDir; + private String[] arg; + + @InputDirectory + public void setSourceDir(File sourceDir) { + this.sourceDir = sourceDir; + } + + public void setWorkDir(File workDir) { + this.workDir = workDir; + } + + @Input + public void commandLine(String ... arg) { + this.arg = arg; + } + + public File getWorkDir() { + return workDir; + } + + public void setOutputDir(File outputDir) { + this.outputDir = outputDir; + } + + @OutputDirectory + public File getOutputDir() { + return outputDir; + } + + @TaskAction + public void executeTestTask() { + if (sourceDir == null || !sourceDir.exists()) { + throw new RuntimeException("Invalid source dir '" + sourceDir + "' given!" ); + } + if (outputDir != null && !outputDir.exists()) { + outputDir.mkdirs(); + } + getProject().copy(new Action() { + public void execute(CopySpec copy) { + copy.from(sourceDir).into(workDir); + } + }); + new DefaultProcessRunner(workDir).run(arg); + } + } + + public static class CompareABTask extends DefaultTask { + } + +} diff --git a/src/main/groovy/org/mockito/release/internal/gradle/CloneGitRepositoryTask.java b/src/main/groovy/org/mockito/release/internal/gradle/CloneGitRepositoryTask.java index 9bed8d28..1190d339 100644 --- a/src/main/groovy/org/mockito/release/internal/gradle/CloneGitRepositoryTask.java +++ b/src/main/groovy/org/mockito/release/internal/gradle/CloneGitRepositoryTask.java @@ -29,7 +29,16 @@ public class CloneGitRepositoryTask extends DefaultTask { @TaskAction public void cloneRepository() { + if (repository == null || repository.isEmpty()) { + throw new RuntimeException("Invalid repository '" + repository + "' given!"); + } + LOG.lifecycle(" Cloning repository {}\n into {}", repository, targetDir); + + if (targetDir.exists()) { + getProject().delete(targetDir); + } + getProject().getBuildDir().mkdirs(); // build dir can be not created yet ProcessRunner processRunner = org.mockito.release.exec.Exec.getProcessRunner(getProject().getBuildDir()); processRunner.run("git", "clone", repository, targetDir.getAbsolutePath()); diff --git a/src/main/groovy/org/mockito/release/internal/gradle/CompareDirectoriesTask.java b/src/main/groovy/org/mockito/release/internal/gradle/CompareDirectoriesTask.java new file mode 100644 index 00000000..64433819 --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/gradle/CompareDirectoriesTask.java @@ -0,0 +1,51 @@ +package org.mockito.release.internal.gradle; + +import org.gradle.api.DefaultTask; +import org.gradle.api.tasks.InputDirectory; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.TaskAction; +import org.mockito.release.internal.comparison.file.CompareResult; +import org.mockito.release.internal.comparison.file.CompareResultSerializer; +import org.mockito.release.internal.comparison.file.FileDifferenceProvider; +import org.mockito.release.notes.util.IOUtil; + +import java.io.File; + +/** + * A Task which compares two given directories. The result will be serialized to a result file. + */ +public class CompareDirectoriesTask extends DefaultTask { + + private File dirA; + private File dirB; + private File resultsFile; + + @InputDirectory + public void setDirA(File dir) { + this.dirA = dir; + } + + @InputDirectory + public void setDirB(File dir) { + this.dirB = dir; + } + + public void setResultsFile(File file) { + this.resultsFile = file; + } + + @OutputFile + public File getResultsFile() { + return resultsFile; + } + + @TaskAction + public void compareDirectories() { + if (resultsFile.exists()) { + getProject().delete(resultsFile); + } + + CompareResult compareResult = new FileDifferenceProvider().getDifference(dirA, dirB); + IOUtil.writeFile(resultsFile, new CompareResultSerializer().serialize(compareResult)); + } +} diff --git a/src/main/groovy/org/mockito/release/internal/util/Md5.java b/src/main/groovy/org/mockito/release/internal/util/Md5.java new file mode 100644 index 00000000..aacf6d94 --- /dev/null +++ b/src/main/groovy/org/mockito/release/internal/util/Md5.java @@ -0,0 +1,48 @@ +package org.mockito.release.internal.util; + + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Md5 utilities + */ +public class Md5 { + + /** + * Calculates the md5 of a given file. + * @param file the file to calculate the md5 for + * @return the resulting md5 + */ + public static String calculate(File file) { + InputStream is = null; + try { + MessageDigest md = MessageDigest.getInstance("md5"); + is = new FileInputStream(file); + + int bytesRead = 0; + byte[] data = new byte[1024]; + while ((bytesRead = is.read(data)) != -1) { + md.update(data, 0, bytesRead); + } + return new BigInteger(1, md.digest()).toString(16); + } catch (java.io.IOException e) { + throw new RuntimeException("error while generating md5 for file " + file, e); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("error while generating md5 for file " + file, e); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + } +} diff --git a/src/main/resources/META-INF/gradle-plugins/org.mockito.mockito-release-tools.build-ab-testing.properties b/src/main/resources/META-INF/gradle-plugins/org.mockito.mockito-release-tools.build-ab-testing.properties new file mode 100644 index 00000000..22c7a9bf --- /dev/null +++ b/src/main/resources/META-INF/gradle-plugins/org.mockito.mockito-release-tools.build-ab-testing.properties @@ -0,0 +1 @@ +implementation-class=org.mockito.release.internal.gradle.BuildABTestingPlugin \ No newline at end of file diff --git a/src/test/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializerTest.groovy b/src/test/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializerTest.groovy new file mode 100644 index 00000000..228ba63b --- /dev/null +++ b/src/test/groovy/org/mockito/release/internal/comparison/file/CompareResultSerializerTest.groovy @@ -0,0 +1,106 @@ +package org.mockito.release.internal.comparison.file + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import spock.lang.Specification +import spock.lang.Subject + + +class CompareResultSerializerTest extends Specification { + + @Subject serializer = new CompareResultSerializer() + + @Rule + TemporaryFolder tmp = new TemporaryFolder() + + File dirA + + def setup() { + dirA = tmp.newFolder('dirA') + } + + def "serialization and deserialization of compareResult (emty)"() { + given: + def compareResult = new CompareResult() + compareResult.setOnlyA([]) + compareResult.setOnlyB([]) + compareResult.setBothButDifferent([]) + + when: + def json = serializer.serialize(compareResult) + def actual = serializer.deserialize(json) + + then: + actual.onlyA == [] + actual.onlyB == [] + actual.bothButDifferent == [] + } + + def "serialization and deserialization of compareResult (onlyA)"() { + given: + def compareResult = new CompareResult() + compareResult.setOnlyA([new File(dirA, "a"), new File(dirA,"b")]) + compareResult.setOnlyB([]) + compareResult.setBothButDifferent([]) + + when: + def json = serializer.serialize(compareResult) + def actual = serializer.deserialize(json) + + then: + actual.onlyA == [new File(dirA, "a"), new File(dirA,"b")] + actual.onlyB == [] + actual.bothButDifferent == [] + } + + def "serialization and deserialization of compareResult (onlyB)"() { + given: + def compareResult = new CompareResult() + compareResult.setOnlyA([]) + compareResult.setOnlyB([new File(dirA, "a"), new File(dirA,"b")]) + compareResult.setBothButDifferent([]) + + when: + def json = serializer.serialize(compareResult) + def actual = serializer.deserialize(json) + + then: + actual.onlyA == [] + actual.onlyB == [new File(dirA, "a"), new File(dirA,"b")] + actual.bothButDifferent == [] + } + + def "serialization and deserialization of compareResult (both)"() { + given: + def compareResult = new CompareResult() + compareResult.setOnlyA([]) + compareResult.setOnlyB([]) + compareResult.setBothButDifferent([new File(dirA, "a"), new File(dirA,"b")]) + + when: + def json = serializer.serialize(compareResult) + def actual = serializer.deserialize(json) + + then: + actual.onlyA == [] + actual.onlyB == [] + actual.bothButDifferent == [new File(dirA, "a"), new File(dirA,"b")] + } + + def "serialization and deserialization of compareResult (full sample)"() { + given: + def compareResult = new CompareResult() + compareResult.setOnlyA([new File(dirA, "x"), new File(dirA,"y")]) + compareResult.setOnlyB([new File(dirA, "u"), new File(dirA,"v"), new File(dirA,"w")]) + compareResult.setBothButDifferent([new File(dirA, "a"), new File(dirA,"b")]) + + when: + def json = serializer.serialize(compareResult) + def actual = serializer.deserialize(json) + + then: + actual.onlyA == [new File(dirA, "x"), new File(dirA,"y")] + actual.onlyB == [new File(dirA, "u"), new File(dirA,"v"), new File(dirA,"w")] + actual.bothButDifferent == [new File(dirA, "a"), new File(dirA,"b")] + } +} diff --git a/src/test/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProviderTest.groovy b/src/test/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProviderTest.groovy new file mode 100644 index 00000000..901790a3 --- /dev/null +++ b/src/test/groovy/org/mockito/release/internal/comparison/file/FileDifferenceProviderTest.groovy @@ -0,0 +1,144 @@ +package org.mockito.release.internal.comparison.file + +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import spock.lang.Specification + + +class FileDifferenceProviderTest extends Specification { + + @Rule + TemporaryFolder tmp = new TemporaryFolder() + + File dirA, dirB + + def setup() { + dirA = tmp.newFolder('dirA') + dirB = tmp.newFolder('dirB') + } + + def "sameContent" () { + given: + createSomeSameContent() + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA.isEmpty() + result.onlyB.isEmpty() + result.bothButDifferent.isEmpty() + } + + def "onlyA" () { + given: + createSomeSameContent() + File dirC = new File(dirA, 'b/c') + dirC.mkdirs() + File fileD = new File(dirC, 'd') + fileD << 'content of d' + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA == [dirC.parentFile, dirC, fileD] + result.onlyB.isEmpty() + result.bothButDifferent.isEmpty() + } + + def "another onlyA" () { + given: + createSomeSameContent() + File dirC = new File(dirA, 'b/c') + dirC.mkdirs() + File fileD = new File(dirC, 'd') + fileD << 'content of d' + File dirT = new File(dirA, 't') + dirT.mkdirs() + File fileU = new File(dirT, 'u') + fileU << 'content of u' + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA == [dirC.parentFile, dirC, fileD, dirT, fileU] + result.onlyB.isEmpty() + result.bothButDifferent.isEmpty() + } + + def "onlyB" () { + given: + createSomeSameContent() + File dirZ = new File(dirB, 'x/y/z') + dirZ.mkdirs() + File fileW = new File(dirZ, 'w') + fileW << 'content of d' + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA.isEmpty() + result.onlyB == [dirZ.parentFile.parentFile, dirZ.parentFile, dirZ, fileW] + result.bothButDifferent.isEmpty() + } + + def "both but different"() { + given: + createSomeSameContent() + + File dirADifferentFile = new File(dirA, 'different') + dirADifferentFile << "someContent" + File dirBDifferentFile = new File(dirB,'different') + dirBDifferentFile << 'differentContent' + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA.isEmpty() + result.onlyB.isEmpty() + result.bothButDifferent == [dirADifferentFile, dirBDifferentFile] + } + + def "both but different (same length)"() { + given: + createSomeSameContent() + + File dirADifferentFile = new File(dirA, 'different') + dirADifferentFile << "content A" + File dirBDifferentFile = new File(dirB,'different') + dirBDifferentFile << 'content B' + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA.isEmpty() + result.onlyB.isEmpty() + result.bothButDifferent == [dirADifferentFile, dirBDifferentFile] + } + + def "same files and same content"() { + given: + createSomeSameContent() + + when: + CompareResult result = new FileDifferenceProvider().getDifference(dirA, dirB); + + then: + result.onlyA.isEmpty() + result.onlyB.isEmpty() + result.bothButDifferent.isEmpty() + } + + private void createSomeSameContent() { + File dirAFile = new File(dirA, 'newFile') + dirAFile << "someContent" + File dirBFile = new File(dirB,'newFile') + dirBFile << 'someContent' + } + +} diff --git a/src/test/groovy/org/mockito/release/internal/gradle/BuildABTestingPluginTest.groovy b/src/test/groovy/org/mockito/release/internal/gradle/BuildABTestingPluginTest.groovy new file mode 100644 index 00000000..80bf8c02 --- /dev/null +++ b/src/test/groovy/org/mockito/release/internal/gradle/BuildABTestingPluginTest.groovy @@ -0,0 +1,14 @@ +package org.mockito.release.internal.gradle + +import org.gradle.testfixtures.ProjectBuilder +import spock.lang.Specification + +class BuildABTestingPluginTest extends Specification { + + def project = new ProjectBuilder().build() + + def "applies"() { + expect: + project.plugins.apply(BuildABTestingPlugin.class); + } +}