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