import java.util.regex.*; import groovy.io.FileType; import java.lang.reflect.Constructor; import java.lang.*; import java.io.*; // This is a map of configuration type names (integration vs. non-integration vs. docking etc..) // to tests (test name, duration) ext.testReport = null; /* * Checks if html test report for an individual test class has a valid name. */ boolean hasValidTestReportClassName(String name) { return name.endsWith("Test.html") && !name.contains("Suite") } /* * Returns duration for a test class report. */ long getDurationFromTestReportClass(String fileContents, String fileName) { /* The duration for the entire test class appears in the test report as (multiline): *
*
0s
* The duration value may appear in the format of: 1m2s, 1m2.3s, 3.4s */ Pattern p = Pattern.compile("(?<=id=\"duration\">[\r\n])(.*?)(?= 0 long durationInMillis // Parse out the duration if (duration.contains("m") && duration.contains("s")) { // has minute and seconds int minutes = Integer.parseInt(duration.substring(0, duration.indexOf("m"))) double seconds = Double.parseDouble(duration.substring(duration.indexOf("m") + 1 , duration.length()-1)) durationInMillis = (minutes * 60 * 1000) + (seconds * 1000) } else if (!duration.contains("m") && duration.contains("s")) { // has only seconds double seconds = Double.parseDouble(duration.substring(0, duration.length()-1)) durationInMillis = (seconds * 1000) } else { // unknown format assert false : "getDurationFromTestReportClass: Unknown duration format in $fileName. 'duration' value is $duration" } logger.debug("getDurationFromTestReportClass: durationInMillis = '"+ durationInMillis +"' parsed from duration = '" + duration + "' in $fileName") return durationInMillis } /* * Creates a map of tests to their durations, organized by the type of * application configuration they require. * * When creating groups of tests to run, we have to ensure that we not only * group them by duration (to make the parallelization more efficient) but also * by the type of application config they require (to avoid a catastrophic test * failure). * * This timing information is gleaned by parsing the html results of a previous * test run. * * The application config information is contained in the resource file * app_config_breakout.txt. * * eg: GhidraAppConfiguration -> DiffTestTypeAdapter, 0.135s */ def Map> getTestReport() { // If we have already created the test report, do not waste time creating // it again. Just return it. if (project.testReport != null) { return project.testReport; } logger.debug("getTestReport: Populating 'testReport' using '$testTimeParserInputDir'") testReport = new HashMap(); List integrationConfigs = new ArrayList<>(); List dockingConfigs = new ArrayList<>(); List appConfigs = new ArrayList<>(); List ghidraConfigs = new ArrayList<>(); parseApplicationConfigs(dockingConfigs, integrationConfigs, appConfigs, ghidraConfigs); File classesReportDir = new File(testTimeParserInputDir) if(!classesReportDir.exists()) { logger.info("getTestReport: The path '$testTimeParserInputDir' does not exist on the file system." + " Returning empty testReport map.") return Collections.emptyMap(); } // These are the configuration 'buckets' that each test class will be dumped // into. Only tests in the same bucket will be run together. Map dockingBucket = new HashMap(); Map integrationBucket = new HashMap(); Map appBucket = new HashMap(); Map ghidraBucket = new HashMap(); Map unknownBucket = new HashMap(); int excludedHtmlFiles = 0 // counter int totalHtmlFiles = 0 String excludedHtmlFileNames = "" // for log.info summary message classesReportDir.eachFileRecurse (FileType.FILES) { file -> totalHtmlFiles++ // Only read html file for a Test and not a test Suite if(hasValidTestReportClassName(file.name)) { String fileContents = file.text /* The fully qualified class name appears in the test report as: *

Class ghidra.app.plugin.assembler.sleigh.BuilderTest

*/ String fqNameFromTestReport = fileContents.find("(?<=

Class\\s).*?(?=

)") int nameIndex = fqNameFromTestReport.lastIndexOf('.') String shortName = fqNameFromTestReport.substring(nameIndex+1); long durationInMillis = getDurationFromTestReportClass(fileContents, file.name) File rootDir = project.rootDir.getParentFile(); File foundFile; fileTree(rootDir.getAbsolutePath()).visit { FileVisitDetails details -> if (details.getName().contains(shortName + ".java")) { foundFile = details.getFile(); } } if (!foundFile.exists()) { // throw error } String javaFileContents = foundFile.text; if (javaFileContents.contains(shortName)) { // Match the word right after "extends", if there is one Pattern p = Pattern.compile("extends\\W+(\\w+)"); Matcher m = p.matcher(javaFileContents); String extendsClass = ""; while (m.find()) { extendsClass = m.group(1); break; } if (extendsClass.isEmpty()) { unknownBucket.put(fqNameFromTestReport, durationInMillis); } else { if (integrationConfigs.contains(extendsClass)) { integrationBucket.put(fqNameFromTestReport, durationInMillis); } else if (dockingConfigs.contains(extendsClass)) { dockingBucket.put(fqNameFromTestReport, durationInMillis); } else if (appConfigs.contains(extendsClass)) { appBucket.put(fqNameFromTestReport, durationInMillis); } else if (ghidraConfigs.contains(extendsClass)) { ghidraBucket.put(fqNameFromTestReport, durationInMillis); } else { unknownBucket.put(fqNameFromTestReport, durationInMillis); } } } testReport.put("integration", integrationBucket); testReport.put("docking", dockingBucket); testReport.put("app", appBucket); testReport.put("ghidra", ghidraBucket); testReport.put("unknown", unknownBucket); logger.debug("getTestReport: Added to testReport: class name = '" + fqNameFromTestReport + "' and durationInMillis = '"+ durationInMillis +"' from " + file.name) } else { logger.debug("getTestReport: Excluding " + file.name + " from test report parsing.") excludedHtmlFileNames += file.name + ", " excludedHtmlFiles++ } } int processedFiles = 0; for (Map.Entry entry : testReport.entrySet()) { Map testMap = entry.getValue(); processedFiles += testMap.size(); } assert totalHtmlFiles != 0 : "getTestReport: Did not parse any valid html files in $testTimeParserInputDir. Directory might be empty" assert totalHtmlFiles == (processedFiles + excludedHtmlFiles) : "Not all html files processed." logger.info("getTestReport:\n" + "\tIncluded " + testReport.size() + " and excluded " + excludedHtmlFiles + " html files out of " + totalHtmlFiles + " in Junit test report.\n" + "\tExcluded html file names are: " + excludedHtmlFileNames + "\n" + "\tParsed test report located at " + testTimeParserInputDir) return project.testReport } /** * Parses the file containing the mapping of test classes to application configs and assigns those * classes to the appropriate lists. */ def parseApplicationConfigs(List dockingConfigs, List integrationConfigs, List appConfigs, List ghidraConfigs) { File breakoutFile = new File(rootProject.projectDir, "gradle/support/app_config_breakout.txt"); String configLines = breakoutFile.text; // Ignore everything up to the first "###" (everything before that // is documentation) configLines = configLines.substring(configLines.indexOf("###")); String[] splitLines = configLines.split("###"); for (int i=0; i . * Then traverses a test sourceSet for a subproject for a test to include and assigns a duration value. * Returns a sorted list of test classes for the sourceSet parameter. */ def Map getTestsForSubProject(SourceDirectorySet sourceDirectorySet) { def testsForSubProject = new HashMap(); int includedClassFilesNotInTestReport = 0 // class in sourceSet but not in test report, 'bumped' to first task int includedClassFilesInTestReport = 0 // class in sourceSet and in test report int excludedClassFilesBadName = 0 // excluded class in sourceSet with invalid name int excludedClassFilesCategory = 0 // excluded class in sourceSet with @Category annotation int excludedClassAllTestsIgnored = 0 // excluded class in sourceSet with test report duration of 0 logger.debug("getTestsForSubProject: Found " + sourceDirectorySet.files.size() + " file(s) in source set to process.") Map testReports = getTestReport(); assert (testReports != null) : "getTestsForSubProject: testReport should not be null" for (File file : sourceDirectorySet.getFiles()) { logger.debug("getTestsForSubProject: Found file in sourceSet = " + file.name) // Must have a valid class name if(!hasValidTestClassName(file.name)) { logger.debug("getTestsForSubProject: Excluding file '" + file.name + "' based on name.") excludedClassFilesBadName++ continue } String fileContents = file.text // Must not have a Category annotation if (hasCategoryExcludes(fileContents)) { logger.debug("getTestsForSubProject: Found category exclude for '" + file.name + "'. Excluding this class from running.") excludedClassFilesCategory++ continue } String fqName = constructFullyQualifiedClassName( fileContents, file.name) boolean foundTest = false; for (Map.Entry entry : testReports.entrySet()) { String configName = entry.getKey(); Map tests = entry.getValue(); if (tests.containsKey(fqName)) { foundTest = true; if (!testsForSubProject.containsKey(configName)) { Map configToTestMap = new LinkedHashMap<>(); testsForSubProject.put(configName, configToTestMap); } Map subTests = testsForSubProject.get(configName); long duration = tests.get(fqName); if (duration > 0) { subTests.put(fqName,duration); logger.debug("getTestsForSubProject: Adding '" + fqName + "'") includedClassFilesInTestReport++ } else { logger.debug("getTestsForSubProject: Excluding '" + fqName + "' because duration from test report is " + duration + "ms. Probably because all test methods are @Ignore'd." ) excludedClassAllTestsIgnored++ } } } if (!foundTest) { // Don't know what this test is so put it in the "unknown" bucket if (!testsForSubProject.containsKey("unknown")) { Map configToTestMap = new LinkedHashMap<>(); testsForSubProject.put("unknown", configToTestMap); } Map subTests = testsForSubProject.get("unknown"); logger.debug("getTestsForSubProject: Found test class not in test report." + " Bumping to front of tasks '" + fqName + "'") subTests.put(fqName, 3600000) // cheap way to bump to front of (eventually) sorted list includedClassFilesNotInTestReport++ } } // Sort by duration for (Map.Entry entry : testsForSubProject.entrySet()) { Map testMap = entry.getValue(); testMap.sort { a, b -> b.value <=> a.value } } /* logger.info ("getTestsForSubProject:\n" + "\tIncluding " + includedClassFilesInTestReport + " test classes for this sourceSet because they are in the test report.\n" + "\tIncluding/bumping " + includedClassFilesNotInTestReport + " not in test report.\n" + "\tExcluding "+ excludedClassFilesBadName +" based on name not ending in 'Test' or contains 'Abstract' or 'Suite', " + excludedClassFilesCategory + " based on '@Category, " + excludedClassAllTestsIgnored + " because duration = 0ms.\n" + "\tReturning sorted list of size "+ sorted.size() + " out of " + sourceDirectorySet.files.size() + " total files found in sourceSet.") */ int filesProcessed = includedClassFilesNotInTestReport + includedClassFilesInTestReport + excludedClassFilesBadName + excludedClassFilesCategory + excludedClassAllTestsIgnored assert sourceDirectorySet.files.size() == filesProcessed : "getTestsForSubProject did not process every file in sourceSet" return testsForSubProject; } /********************************************************************************* * Determines if test task creation should be skipped for parallelCombinedTestReport task. *********************************************************************************/ def boolean shouldSkipTestTaskCreation(Project subproject) { if (!parallelMode) { logger.debug("shouldSkipTestTaskCreation: Skip task creation for $subproject.name. Not in parallel mode.") return true } if (!subproject.hasProperty("sourceSets")) { logger.debug("shouldSkipTestTaskCreation: subproject $subproject.name has no sourceSet property.") return true } if (subproject.sourceSets.findByName("test") == null || subproject.sourceSets.test.java.files.isEmpty()) { logger.debug("shouldSkipTestTaskCreation: Skip task creation for $subproject.name. No test sources.") return true } if (subproject.findProperty("excludeFromParallelTests") ?: false) { logger.debug("shouldSkipTestTaskCreation: Skip task creation for $subproject.name." + " 'excludeFromParallelTests' found.") return true } if (subproject.findProperty("repoToTest")) { subproject.ext.repoToTest = subproject.getProperty('repoToTest') } else { subproject.ext.repoToTest = "ALL_REPOS" } if (!subproject.ext.repoToTest.equals("ALL_REPOS") && !subproject.getProjectDir().toString().contains("/" + repoToTest + "/")) { return true } return false } /********************************************************************************* * Determines if integrationTest task creation should be skipped for parallelCombinedTestReport task. *********************************************************************************/ def boolean shouldSkipIntegrationTestTaskCreation(Project subproject) { if (!parallelMode) { logger.debug("shouldSkipIntegrationTestTaskCreation: Skip task creation for $subproject.name." + " Not in parallel mode.") return true } if (!subproject.hasProperty("sourceSets")) { logger.debug("shouldSkipIntegrationTestTaskCreation: subproject $subproject.name has no sourceSet property.") return true } if (subproject.sourceSets.findByName("integrationTest") == null || subproject.sourceSets.integrationTest.java.files.isEmpty()) { logger.debug("shouldSkipIntegrationTestTaskCreation: Skip task creation for $subproject.name." + " No integrationTest sources.") return true } if (subproject.findProperty("excludeFromParallelIntegrationTests") ?: false) { logger.debug("shouldSkipIntegrationTestTaskCreation: Skip task creation for $subproject.name." + "'excludeFromParallelIntegrationTests' found.") return true } if (subproject.findProperty("repoToTest")) { subproject.ext.repoToTest = subproject.getProperty('repoToTest') } else { subproject.ext.repoToTest = "ALL_REPOS" } if (!subproject.ext.repoToTest.equals("ALL_REPOS") && !subproject.getProjectDir().toString().contains("/" + repoToTest + "/")) { return true } return false } /********************************************************************************* * Gets the path to the last archived test report. This is used by the * 'parallelCombinedTestReport' task when no -PtestTimeParserInputDir is supplied * via cmd line. *********************************************************************************/ def String getLastArchivedReport(String reportArchivesPath) { // skip configuration for this property if not in parallelMode if (!parallelMode) { logger.debug("getLastArchivedReport: not in 'parallelMode'. Skipping.") return "" } File reportArchiveDir = new File(reportArchivesPath); logger.info("getLastArchivedReport: searching for test report to parse in " + reportArchivesPath) if(!reportArchiveDir.exists()) { logger.info("getLastArchivedReport: '$reportArchiveDir' does not exist.") return "" } // filter for report archive directories. File[] files = reportArchiveDir.listFiles(new FilenameFilter() { public boolean accept(File dir, String name) { return name.startsWith("reports_"); } }); assert (files != null && files.size() > 0) : """Could not find test report archives in '$reportArchiveDir'. because no -PtestTimeParserInputDir= supplied via cmd line""" logger.debug("getLastArchivedReport: found " + files.size() + " archived report directories in '" + reportArchiveDir.getPath() + "'.") // Sort by lastModified date. The last modified directory will be first. files = files.sort{-it.lastModified()} logger.debug("getLastArchivedReport: selecting report archive to parse: " + files[0].getAbsolutePath()) return files[0].getAbsolutePath() } ext { getTestsForSubProject = this.&getTestsForSubProject // export this utility method to project shouldSkipTestTaskCreation = this.&shouldSkipTestTaskCreation shouldSkipIntegrationTestTaskCreation = this.&shouldSkipIntegrationTestTaskCreation getLastArchivedReport = this.&getLastArchivedReport }