mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-06 03:50:02 +02:00
532 lines
20 KiB
Groovy
532 lines
20 KiB
Groovy
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):
|
|
* <div class="infoBox" id="duration">
|
|
* <div class="counter">0s</div>
|
|
* The duration value may appear in the format of: 1m2s, 1m2.3s, 3.4s
|
|
*/
|
|
Pattern p = Pattern.compile("(?<=id=\"duration\">[\r\n]<div\\sclass=\"counter\">)(.*?)(?=</div)",
|
|
Pattern.MULTILINE);
|
|
Matcher m = p.matcher(fileContents);
|
|
assert m.find() == true
|
|
String duration = m.group()
|
|
assert duration != null && duration.trim().length() > 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<String, Map<String, Long>> 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<String,Map>();
|
|
|
|
List<String> integrationConfigs = new ArrayList<>();
|
|
List<String> dockingConfigs = new ArrayList<>();
|
|
List<String> appConfigs = new ArrayList<>();
|
|
List<String> 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<String, Long>();
|
|
Map integrationBucket = new HashMap<String, Long>();
|
|
Map appBucket = new HashMap<String, Long>();
|
|
Map ghidraBucket = new HashMap<String, Long>();
|
|
Map unknownBucket = new HashMap<String, Long>();
|
|
|
|
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:
|
|
* <h1>Class ghidra.app.plugin.assembler.sleigh.BuilderTest</h1>
|
|
*/
|
|
String fqNameFromTestReport = fileContents.find("(?<=<h1>Class\\s).*?(?=</h1>)")
|
|
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<String,Map> entry : testReport.entrySet()) {
|
|
Map<String,Long> 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<splitLines.size(); i++) {
|
|
String block = splitLines[i];
|
|
if (block.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
// Grab the header (and remove it from the main string)
|
|
String header = block.substring(0,block.indexOf("^"));
|
|
block = block.substring(header.length() + 1);
|
|
String[] classes = block.split("\n");
|
|
if (header.equals("HeadlessGhidraApplicationConfig")) {
|
|
for (int j=0;j<classes.size(); j++) {
|
|
String cl = classes[j].trim();
|
|
if (cl.isEmpty()) {
|
|
continue;
|
|
}
|
|
logger.info("adding " + cl + " to integrationConfigs");
|
|
integrationConfigs.add(cl);
|
|
}
|
|
}
|
|
else if (header.equals("DockingApplicationConfiguration")) {
|
|
for (int j=0;j<classes.size(); j++) {
|
|
String cl = classes[j].trim();
|
|
if (cl.isEmpty()) {
|
|
continue;
|
|
}
|
|
logger.info("adding " + cl + " to dockingConfigs");
|
|
dockingConfigs.add(cl);
|
|
}
|
|
}
|
|
else if (header.equals("ApplicationConfiguration")) {
|
|
for (int j=0;j<classes.size(); j++) {
|
|
String cl = classes[j].trim();
|
|
if (cl.isEmpty()) {
|
|
continue;
|
|
}
|
|
logger.info("adding " + cl + " to appConfigs");
|
|
appConfigs.add(cl);
|
|
}
|
|
}
|
|
else if (header.equals("GhidraAppConfiguration")) {
|
|
for (int j=0;j<classes.size(); j++) {
|
|
String cl = classes[j].trim();
|
|
if (cl.isEmpty()) {
|
|
continue;
|
|
}
|
|
logger.info("adding " + cl + " to ghidraConfigs");
|
|
ghidraConfigs.add(cl);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Checks if Java test class has a valid name.
|
|
*/
|
|
boolean hasValidTestClassName(String name) {
|
|
return name != null && name.endsWith("Test.java") &&
|
|
!(name.contains("Abstract") || name.contains("Suite"))
|
|
}
|
|
|
|
/*
|
|
* Checks if Java test class is excluded via 'org.junit.experimental.categories.Category'
|
|
*/
|
|
boolean hasCategoryExcludes(String fileContents) {
|
|
|
|
String annotation1 = "@Category\\(PortSensitiveCategory.class\\)" // evaluated as regex
|
|
String annotation2 = "@Category\\(NightlyCategory.class\\)"
|
|
|
|
return fileContents.find(annotation1) || fileContents.find(annotation2)
|
|
}
|
|
|
|
/*
|
|
* Returns a fully qualified class name from a java class.
|
|
*/
|
|
String constructFullyQualifiedClassName(String fileContents, String fileName) {
|
|
String packageName = fileContents.find("(?<=package\\s).*?(?=;)")
|
|
logger.debug("constructFullyQualifiedClassName: Found '" + packageName + "' in " + fileName)
|
|
|
|
assert packageName != null : "constructFullyQualifiedClassName: Null packageName found in $fileName"
|
|
assert !packageName.startsWith("package")
|
|
assert !packageName.endsWith(";")
|
|
|
|
return packageName + "." + fileName.replace(".java","")
|
|
}
|
|
|
|
/* Creates a list of test classes, sorted by duration, for a subproject.
|
|
* First parses JUnit test report located at 'testTimeParserInputDir' for <fully qualified class name, duration in milliseconds> .
|
|
* 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<String, Map> getTestsForSubProject(SourceDirectorySet sourceDirectorySet) {
|
|
|
|
def testsForSubProject = new HashMap<String,LinkedHashMap>();
|
|
|
|
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<String,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<String,Map> entry : testReports.entrySet()) {
|
|
String configName = entry.getKey();
|
|
Map<String,Long> tests = entry.getValue();
|
|
|
|
if (tests.containsKey(fqName)) {
|
|
foundTest = true;
|
|
if (!testsForSubProject.containsKey(configName)) {
|
|
Map<String,Map> configToTestMap = new LinkedHashMap<>();
|
|
testsForSubProject.put(configName, configToTestMap);
|
|
}
|
|
|
|
Map<String,Long> 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<String,Map> configToTestMap = new LinkedHashMap<>();
|
|
testsForSubProject.put("unknown", configToTestMap);
|
|
}
|
|
|
|
Map<String,Long> 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<String,Map> entry : testsForSubProject.entrySet()) {
|
|
Map<String,Long> 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=<path/to/report> 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
|
|
}
|