1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.tradefed.util.testmapping; 17 18 import com.android.tradefed.build.IBuildInfo; 19 import com.android.tradefed.log.LogUtil.CLog; 20 import com.android.tradefed.util.FileUtil; 21 import com.android.tradefed.util.ZipUtil2; 22 23 import com.google.common.annotations.VisibleForTesting; 24 25 import org.json.JSONArray; 26 import org.json.JSONException; 27 import org.json.JSONObject; 28 import org.json.JSONTokener; 29 30 import java.io.File; 31 import java.io.IOException; 32 import java.nio.charset.StandardCharsets; 33 import java.nio.file.FileVisitOption; 34 import java.nio.file.Files; 35 import java.nio.file.Path; 36 import java.nio.file.Paths; 37 import java.util.ArrayList; 38 import java.util.Arrays; 39 import java.util.HashMap; 40 import java.util.HashSet; 41 import java.util.Iterator; 42 import java.util.LinkedHashMap; 43 import java.util.List; 44 import java.util.Map; 45 import java.util.Set; 46 import java.util.regex.Matcher; 47 import java.util.regex.Pattern; 48 import java.util.stream.Collectors; 49 import java.util.stream.Stream; 50 51 /** A class for loading a TEST_MAPPING file. */ 52 public class TestMapping { 53 54 // Key for test sources information stored in meta data of ConfigurationDescription. 55 public static final String TEST_SOURCES = "Test Sources"; 56 57 private static final String PRESUBMIT = "presubmit"; 58 private static final String IMPORTS = "imports"; 59 private static final String KEY_HOST = "host"; 60 private static final String KEY_KEYWORDS = "keywords"; 61 private static final String KEY_NAME = "name"; 62 private static final String KEY_OPTIONS = "options"; 63 private static final String TEST_MAPPING = "TEST_MAPPING"; 64 private static final String TEST_MAPPINGS_ZIP = "test_mappings.zip"; 65 // A file containing module names that are disabled in presubmit test runs. 66 private static final String DISABLED_PRESUBMIT_TESTS_FILE = "disabled-presubmit-tests"; 67 68 private Map<String, Set<TestInfo>> mTestCollection = null; 69 // Pattern used to identify comments start with "//" or "#" in TEST_MAPPING. 70 private static final Pattern COMMENTS_REGEX = Pattern.compile( 71 "(?m)[\\s\\t]*(//|#).*|(\".*?\")"); 72 private static final Set<String> COMMENTS = new HashSet<>(Arrays.asList("#", "//")); 73 74 private static List<String> mTestMappingRelativePaths = new ArrayList<>(); 75 76 /** 77 * Set the TEST_MAPPING paths inside of TEST_MAPPINGS_ZIP to limit loading the TEST_MAPPING. 78 * 79 * @param relativePaths A {@code List<String>} of TEST_MAPPING paths relative to 80 * TEST_MAPPINGS_ZIP. 81 */ setTestMappingPaths(List<String> relativePaths)82 public static void setTestMappingPaths(List<String> relativePaths) { 83 mTestMappingRelativePaths.clear(); 84 mTestMappingRelativePaths.addAll(relativePaths); 85 } 86 87 88 /** 89 * Constructor to create a {@link TestMapping} object from a path to TEST_MAPPING file. 90 * 91 * @param path The {@link Path} to a TEST_MAPPING file. 92 * @param testMappingsDir The {@link Path} to the folder of all TEST_MAPPING files for a build. 93 */ TestMapping(Path path, Path testMappingsDir)94 public TestMapping(Path path, Path testMappingsDir) { 95 mTestCollection = new LinkedHashMap<>(); 96 String relativePath = testMappingsDir.relativize(path.getParent()).toString(); 97 String errorMessage = null; 98 try { 99 String content = removeComments( 100 String.join("\n", Files.readAllLines(path, StandardCharsets.UTF_8))); 101 if (content != null) { 102 JSONTokener tokener = new JSONTokener(content); 103 JSONObject root = new JSONObject(tokener); 104 Iterator<String> testGroups = (Iterator<String>) root.keys(); 105 while (testGroups.hasNext()) { 106 String group = testGroups.next(); 107 if (group.equals(IMPORTS)) { 108 // TF runs tests in all TEST_MAPPING files in a build, so imports do not 109 // need to be considered. 110 continue; 111 } 112 Set<TestInfo> testsForGroup = new HashSet<>(); 113 mTestCollection.put(group, testsForGroup); 114 JSONArray arr = root.getJSONArray(group); 115 for (int i = 0; i < arr.length(); i++) { 116 JSONObject testObject = arr.getJSONObject(i); 117 boolean hostOnly = 118 testObject.has(KEY_HOST) && testObject.getBoolean(KEY_HOST); 119 Set<String> keywords = new HashSet<>(); 120 if (testObject.has(KEY_KEYWORDS)) { 121 JSONArray keywordArray = testObject.getJSONArray(KEY_KEYWORDS); 122 for (int j = 0; j < keywordArray.length(); j++) { 123 keywords.add(keywordArray.getString(j)); 124 } 125 } 126 TestInfo test = 127 new TestInfo( 128 testObject.getString(KEY_NAME), 129 relativePath, 130 hostOnly, 131 keywords); 132 if (testObject.has(KEY_OPTIONS)) { 133 JSONArray optionObjects = testObject.getJSONArray(KEY_OPTIONS); 134 for (int j = 0; j < optionObjects.length(); j++) { 135 JSONObject optionObject = optionObjects.getJSONObject(j); 136 for (int k = 0; k < optionObject.names().length(); k++) { 137 String name = optionObject.names().getString(k); 138 String value = optionObject.getString(name); 139 TestOption option = new TestOption(name, value); 140 test.addOption(option); 141 } 142 } 143 } 144 testsForGroup.add(test); 145 } 146 } 147 } 148 } catch (IOException e) { 149 errorMessage = String.format("TEST_MAPPING file does not exist: %s.", path.toString()); 150 CLog.e(errorMessage); 151 } catch (JSONException e) { 152 errorMessage = 153 String.format( 154 "Error parsing TEST_MAPPING file: %s. Error: %s", path.toString(), e); 155 } 156 157 if (errorMessage != null) { 158 CLog.e(errorMessage); 159 throw new RuntimeException(errorMessage); 160 } 161 } 162 163 /** 164 * Helper to remove comments in a TEST_MAPPING file to valid format. Only "//" and "#" are 165 * regarded as comments. 166 * 167 * @param jsonContent A {@link String} of json which content is from a TEST_MAPPING file. 168 * @return A {@link String} of valid json without comments. 169 */ 170 @VisibleForTesting removeComments(String jsonContent)171 static String removeComments(String jsonContent) { 172 StringBuffer out = new StringBuffer(); 173 Matcher matcher = COMMENTS_REGEX.matcher(jsonContent); 174 while (matcher.find()) { 175 if (COMMENTS.contains(matcher.group(1))) { 176 matcher.appendReplacement(out, ""); 177 } 178 } 179 matcher.appendTail(out); 180 return out.toString(); 181 } 182 183 /** 184 * Helper to get all tests set in a TEST_MAPPING file for a given group. 185 * 186 * @param testGroup A {@link String} of the test group. 187 * @param disabledTests A set of {@link String} for the name of the disabled tests. 188 * @param hostOnly true if only tests running on host and don't require device should be 189 * returned. false to return tests that require device to run. 190 * @param keywords A set of {@link String} to be matched when filtering tests to run in a Test 191 * Mapping suite. 192 * @return A {@code Set<TestInfo>} of the test infos. 193 */ getTests( String testGroup, Set<String> disabledTests, boolean hostOnly, Set<String> keywords)194 public Set<TestInfo> getTests( 195 String testGroup, Set<String> disabledTests, boolean hostOnly, Set<String> keywords) { 196 Set<TestInfo> tests = new HashSet<TestInfo>(); 197 198 for (TestInfo test : mTestCollection.getOrDefault(testGroup, new HashSet<>())) { 199 if (disabledTests != null && disabledTests.contains(test.getName())) { 200 continue; 201 } 202 if (test.getHostOnly() != hostOnly) { 203 continue; 204 } 205 // Skip the test if no keyword is specified but the test requires certain keywords. 206 if ((keywords == null || keywords.isEmpty()) && !test.getKeywords().isEmpty()) { 207 continue; 208 } 209 // Skip the test if any of the required keywords is not specified by the test. 210 if (keywords != null) { 211 Boolean allKeywordsFound = true; 212 for (String keyword : keywords) { 213 if (!test.getKeywords().contains(keyword)) { 214 allKeywordsFound = false; 215 break; 216 } 217 } 218 // The test should be skipped if any keyword is missing in the test configuration. 219 if (!allKeywordsFound) { 220 continue; 221 } 222 } 223 tests.add(test); 224 } 225 226 return tests; 227 } 228 229 /** 230 * Merge multiple tests if there are any for the same test module, but with different test 231 * options. 232 * 233 * @param tests A {@code Set<TestInfo>} of the test infos to be processed. 234 * @return A {@code Set<TestInfo>} of tests that each is for a unique test module. 235 */ mergeTests(Set<TestInfo> tests)236 private static Set<TestInfo> mergeTests(Set<TestInfo> tests) { 237 Map<String, List<TestInfo>> testsGroupedbyNameAndHost = 238 tests.stream() 239 .collect( 240 Collectors.groupingBy( 241 TestInfo::getNameAndHostOnly, Collectors.toList())); 242 243 Set<TestInfo> mergedTests = new HashSet<>(); 244 for (List<TestInfo> multiTests : testsGroupedbyNameAndHost.values()) { 245 TestInfo mergedTest = multiTests.get(0); 246 if (multiTests.size() > 1) { 247 for (TestInfo test : multiTests.subList(1, multiTests.size())) { 248 mergedTest.merge(test); 249 } 250 } 251 mergedTests.add(mergedTest); 252 } 253 254 return mergedTests; 255 } 256 257 /** 258 * Helper to get all TEST_MAPPING paths relative to TEST_MAPPINGS_ZIP. 259 * 260 * @param testMappingsRootPath The {@link Path} to a test mappings zip path. 261 * @return A {@code Set<Path>} of all the TEST_MAPPING paths relative to TEST_MAPPINGS_ZIP. 262 */ 263 @VisibleForTesting getAllTestMappingPaths(Path testMappingsRootPath)264 static Set<Path> getAllTestMappingPaths(Path testMappingsRootPath) { 265 Set<Path> allTestMappingPaths = new HashSet<>(); 266 for (String path : mTestMappingRelativePaths) { 267 boolean hasAdded = false; 268 Path testMappingPath = testMappingsRootPath.resolve(path); 269 // Recursively find the TEST_MAPPING file until reaching to testMappingsRootPath. 270 while (!testMappingPath.equals(testMappingsRootPath)) { 271 if (testMappingPath.resolve(TEST_MAPPING).toFile().exists()) { 272 hasAdded = true; 273 CLog.d("Adding TEST_MAPPING path: %s", testMappingPath); 274 allTestMappingPaths.add(testMappingPath.resolve(TEST_MAPPING)); 275 } 276 testMappingPath = testMappingPath.getParent(); 277 } 278 if (!hasAdded) { 279 CLog.w("Couldn't find TEST_MAPPING files from %s", path); 280 } 281 } 282 if (allTestMappingPaths.isEmpty()) { 283 throw new RuntimeException( 284 String.format( 285 "Couldn't find TEST_MAPPING files from %s", mTestMappingRelativePaths)); 286 } 287 return allTestMappingPaths; 288 } 289 290 /** 291 * Helper to find all tests in all TEST_MAPPING files. This is needed when a suite run requires 292 * to run all tests in TEST_MAPPING files for a given group, e.g., presubmit. 293 * 294 * @param buildInfo the {@link IBuildInfo} describing the build. 295 * @param testGroup a {@link String} of the test group. 296 * @param hostOnly true if only tests running on host and don't require device should be 297 * returned. false to return tests that require device to run. 298 * @return A {@code Set<TestInfo>} of tests set in the build artifact, test_mappings.zip. 299 */ getTests( IBuildInfo buildInfo, String testGroup, boolean hostOnly, Set<String> keywords)300 public static Set<TestInfo> getTests( 301 IBuildInfo buildInfo, String testGroup, boolean hostOnly, Set<String> keywords) { 302 Set<TestInfo> tests = new HashSet<TestInfo>(); 303 File testMappingsDir = extractTestMappingsZip(buildInfo.getFile(TEST_MAPPINGS_ZIP)); 304 Stream<Path> stream = null; 305 try { 306 Path testMappingsRootPath = Paths.get(testMappingsDir.getAbsolutePath()); 307 Set<String> disabledTests = getDisabledTests(testMappingsRootPath, testGroup); 308 if (mTestMappingRelativePaths.isEmpty()) { 309 stream = Files.walk(testMappingsRootPath, FileVisitOption.FOLLOW_LINKS); 310 } 311 else { 312 stream = getAllTestMappingPaths(testMappingsRootPath).stream(); 313 } 314 stream.filter(path -> path.getFileName().toString().equals(TEST_MAPPING)) 315 .forEach( 316 path -> 317 tests.addAll( 318 new TestMapping(path, testMappingsRootPath) 319 .getTests( 320 testGroup, 321 disabledTests, 322 hostOnly, 323 keywords))); 324 325 } catch (IOException e) { 326 throw new RuntimeException( 327 String.format( 328 "IO exception (%s) when reading tests from TEST_MAPPING files (%s)", 329 e.getMessage(), testMappingsDir.getAbsolutePath()), e); 330 } finally { 331 if (stream != null) { 332 stream.close(); 333 } 334 FileUtil.recursiveDelete(testMappingsDir); 335 } 336 337 return tests; 338 } 339 340 /** 341 * Helper to find all tests in the TEST_MAPPING files from a given directory. 342 * 343 * @param testMappingsDir the {@link File} the directory containing all Test Mapping files. 344 * @return A {@code Map<String, Set<TestInfo>>} of tests in the given directory and its child 345 * directories. 346 */ getAllTests(File testMappingsDir)347 public static Map<String, Set<TestInfo>> getAllTests(File testMappingsDir) { 348 Map<String, Set<TestInfo>> allTests = new HashMap<String, Set<TestInfo>>(); 349 Stream<Path> stream = null; 350 try { 351 Path testMappingsRootPath = Paths.get(testMappingsDir.getAbsolutePath()); 352 stream = Files.walk(testMappingsRootPath, FileVisitOption.FOLLOW_LINKS); 353 stream.filter(path -> path.getFileName().toString().equals(TEST_MAPPING)) 354 .forEach( 355 path -> 356 getAllTests(allTests, path, testMappingsRootPath)); 357 358 } catch (IOException e) { 359 throw new RuntimeException( 360 String.format( 361 "IO exception (%s) when reading tests from TEST_MAPPING files (%s)", 362 e.getMessage(), testMappingsDir.getAbsolutePath()), e); 363 } finally { 364 if (stream != null) { 365 stream.close(); 366 } 367 } 368 return allTests; 369 } 370 371 /** 372 * Extract a zip file and return the directory that contains the content of unzipped files. 373 * 374 * @param testMappingsZip A {@link File} of the test mappings zip to extract. 375 * @return a {@link File} pointing to the temp directory for test mappings zip. 376 */ extractTestMappingsZip(File testMappingsZip)377 public static File extractTestMappingsZip(File testMappingsZip) { 378 File testMappingsDir = null; 379 try { 380 testMappingsDir = ZipUtil2.extractZipToTemp(testMappingsZip, TEST_MAPPINGS_ZIP); 381 } catch (IOException e) { 382 throw new RuntimeException( 383 String.format( 384 "IO exception (%s) when extracting test mappings zip (%s)", 385 e.getMessage(), testMappingsZip.getAbsolutePath()), e); 386 } 387 return testMappingsDir; 388 } 389 390 /** 391 * Get disabled tests from test mapping artifact. 392 * 393 * @param testMappingsRootPath The {@link Path} to a test mappings zip path. 394 * @param testGroup a {@link String} of the test group. 395 * @return a {@link Set<String>} containing all the disabled presubmit tests. No test is 396 * returned if the testGroup is not PRESUBMIT. 397 */ 398 @VisibleForTesting getDisabledTests(Path testMappingsRootPath, String testGroup)399 static Set<String> getDisabledTests(Path testMappingsRootPath, String testGroup) { 400 Set<String> disabledTests = new HashSet<>(); 401 File disabledPresubmitTestsFile = 402 new File(testMappingsRootPath.toString(), DISABLED_PRESUBMIT_TESTS_FILE); 403 if (!(testGroup.equals(PRESUBMIT) && disabledPresubmitTestsFile.exists())) { 404 return disabledTests; 405 } 406 try { 407 disabledTests.addAll( 408 Arrays.asList( 409 FileUtil.readStringFromFile(disabledPresubmitTestsFile) 410 .split("\\r?\\n"))); 411 } catch (IOException e) { 412 throw new RuntimeException( 413 String.format( 414 "IO exception (%s) when reading disabled tests from file (%s)", 415 e.getMessage(), disabledPresubmitTestsFile.getAbsolutePath()), e); 416 } 417 return disabledTests; 418 } 419 420 /** 421 * Helper to find all tests in the TEST_MAPPING files from a given directory. 422 * 423 * @param allTests the {@code HashMap<String, Set<TestInfo>>} containing the tests of each 424 * test group. 425 * @param path the {@link Path} to a TEST_MAPPING file. 426 * @param testMappingsRootPath the {@link Path} to a test mappings zip path. 427 */ getAllTests(Map<String, Set<TestInfo>> allTests, Path path, Path testMappingsRootPath)428 private static void getAllTests(Map<String, Set<TestInfo>> allTests, 429 Path path, Path testMappingsRootPath) { 430 Map<String, Set<TestInfo>> testCollection = 431 new TestMapping(path, testMappingsRootPath).getTestCollection(); 432 for (String group : testCollection.keySet()) { 433 allTests.computeIfAbsent(group, k -> new HashSet<>()).addAll(testCollection.get(group)); 434 } 435 } 436 437 /** 438 * Helper to get the test collection in a TEST_MAPPING file. 439 * 440 * @return A {@code Map<String, Set<TestInfo>>} containing the test collection in a 441 * TEST_MAPPING file. 442 */ getTestCollection()443 private Map<String, Set<TestInfo>> getTestCollection() { 444 return mTestCollection; 445 } 446 } 447