• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 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.uicd.tests;
17 
18 import com.android.ddmlib.testrunner.TestResult.TestStatus;
19 import com.android.tradefed.config.Option;
20 import com.android.tradefed.config.OptionClass;
21 import com.android.tradefed.device.DeviceNotAvailableException;
22 import com.android.tradefed.device.ITestDevice;
23 import com.android.tradefed.invoker.IInvocationContext;
24 import com.android.tradefed.invoker.TestInformation;
25 import com.android.tradefed.invoker.logger.CurrentInvocation;
26 import com.android.tradefed.log.LogUtil.CLog;
27 import com.android.tradefed.result.CollectingTestListener;
28 import com.android.tradefed.result.FileInputStreamSource;
29 import com.android.tradefed.result.ITestInvocationListener;
30 import com.android.tradefed.result.LogDataType;
31 import com.android.tradefed.result.TestDescription;
32 import com.android.tradefed.result.TestResult;
33 import com.android.tradefed.result.proto.FileProtoResultReporter;
34 import com.android.tradefed.result.proto.TestRecordProto;
35 import com.android.tradefed.testtype.IRemoteTest;
36 import com.android.tradefed.testtype.ITestFilterReceiver;
37 import com.android.tradefed.util.CommandResult;
38 import com.android.tradefed.util.FileUtil;
39 import com.android.tradefed.util.IRunUtil;
40 import com.android.tradefed.util.MultiMap;
41 import com.android.tradefed.util.RunUtil;
42 import com.android.tradefed.util.TestRecordInterpreter;
43 import com.android.tradefed.util.proto.TestRecordProtoUtil;
44 
45 import com.google.common.annotations.VisibleForTesting;
46 
47 import org.json.JSONArray;
48 import org.json.JSONException;
49 import org.json.JSONObject;
50 
51 import java.io.File;
52 import java.io.IOException;
53 import java.io.UncheckedIOException;
54 import java.nio.file.Files;
55 import java.nio.file.Path;
56 import java.time.Duration;
57 import java.util.ArrayList;
58 import java.util.Arrays;
59 import java.util.Collection;
60 import java.util.HashSet;
61 import java.util.List;
62 import java.util.Map;
63 import java.util.Set;
64 import java.util.stream.Collectors;
65 import java.util.stream.Stream;
66 
67 import javax.annotation.Nullable;
68 
69 /**
70  * Runs pre-recorded Android UIConductor tests in Tradefed. Each provided JSON file is treated as a
71  * test case. Supports automatic retries, including file-based retries across invocations using
72  * {@link UiConductorTest.ResultReporter}. See XML configurations in res/config/uicd for examples.
73  *
74  * <p>See Also: https://github.com/google/android-uiconductor
75  * https://console.cloud.google.com/storage/browser/uicd-deps
76  */
77 @OptionClass(alias = "uicd")
78 public class UiConductorTest implements IRemoteTest, ITestFilterReceiver {
79 
80     static final String MODULE_NAME = UiConductorTest.class.getSimpleName();
81     static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(30L);
82     static final String DEFAULT_OUTPUT_PATH = "uicd_results.pb";
83 
84     static final String INPUT_OPTION = "--input";
85     static final String OUTPUT_OPTION = "--output";
86     static final String DEVICES_OPTION = "--devices";
87     static final String MODE_OPTION = "--mode";
88     static final String GLOBAL_VARIABLE_OPTION = "--global_variable";
89 
90     static final String TEST_RESULT_PATH = "result/action_execution_result";
91 
92     /** Testing mode. */
93     public enum PlayMode {
94         SINGLE,
95         MULTIDEVICE,
96         PLAYALL,
97     }
98 
99     /** Test case information, contains the test file and its metadata. */
100     private static class UiConductorTestCase {
101         private final String mId;
102         private final String mKey;
103         private final File mFile;
104         private final TestDescription mDesc;
105 
UiConductorTestCase(String id, String key, File file)106         private UiConductorTestCase(String id, String key, File file) {
107             mId = id;
108             mKey = key;
109             mFile = file;
110             mDesc = new TestDescription(MODULE_NAME, mId);
111         }
112     }
113 
114     @Option(name = "work-dir", description = "Optional work directory to use")
115     private File mWorkDir;
116 
117     @Option(
118             name = "uicd-cli-jar",
119             description = "UICD CLI jar to use when running tests",
120             mandatory = true)
121     private File mCliJar;
122 
123     @Option(
124             name = "commandline-action-executable",
125             description = "Additional binaries needed by command line actions. Can be repeated.")
126     private Collection<File> mBinaries = new ArrayList<>();
127 
128     @Option(
129             name = "global-variables",
130             description = "Global variable (uicd_key1=value1,uicd_key2=value2)")
131     private MultiMap<String, String> mGlobalVariables = new MultiMap<>();
132 
133     @Option(name = "play-mode", description = "Play mode (SINGLE|MULTIDEVICE|PLAYALL)")
134     private PlayMode mPlayMode = PlayMode.SINGLE;
135 
136     // Same key can have multiple test files because global-variables can be referenced using the
137     // that particular key and shared across different tests.
138     // Refer res/config/uicd/uiconductor-globalvariable-sample.xml for more information.
139     @Option(
140             name = "uicd-test",
141             description = "JSON test file or directory of JSON test files to run. Can be repeated.",
142             mandatory = true)
143     private MultiMap<String, File> mTests = new MultiMap<>();
144 
145     @Option(name = "test-timeout", description = "Timeout for each test case")
146     private Duration mTestTimeout = DEFAULT_TIMEOUT;
147 
148     @Option(name = "include-filter", description = "Regex filters used to find tests to include")
149     private Set<String> mIncludeFilters = new HashSet<>();
150 
151     @Option(name = "exclude-filter", description = "Regex filters used to find tests to exclude")
152     private Set<String> mExcludeFilters = new HashSet<>();
153 
154     @Option(name = "previous-results", description = "Previous output file to load when retrying")
155     private File mPreviousResults;
156 
157     private IRunUtil mRunUtil;
158     private Path mOutputDir;
159 
160     @Override
addIncludeFilter(String filter)161     public void addIncludeFilter(String filter) {
162         mIncludeFilters.add(filter);
163     }
164 
165     @Override
addAllIncludeFilters(Set<String> filters)166     public void addAllIncludeFilters(Set<String> filters) {
167         mIncludeFilters.addAll(filters);
168     }
169 
170     @Override
addExcludeFilter(String filter)171     public void addExcludeFilter(String filter) {
172         mExcludeFilters.add(filter);
173     }
174 
175     @Override
addAllExcludeFilters(Set<String> filters)176     public void addAllExcludeFilters(Set<String> filters) {
177         mExcludeFilters.addAll(filters);
178     }
179 
180     @Override
getIncludeFilters()181     public Set<String> getIncludeFilters() {
182         return mIncludeFilters;
183     }
184 
185     @Override
getExcludeFilters()186     public Set<String> getExcludeFilters() {
187         return mExcludeFilters;
188     }
189 
190     @Override
clearIncludeFilters()191     public void clearIncludeFilters() {
192         mIncludeFilters.clear();
193     }
194 
195     @Override
clearExcludeFilters()196     public void clearExcludeFilters() {
197         mExcludeFilters.clear();
198     }
199 
200     @Override
run(TestInformation testInfo, ITestInvocationListener listener)201     public void run(TestInformation testInfo, ITestInvocationListener listener)
202             throws DeviceNotAvailableException {
203         if (!mCliJar.isFile()) {
204             throw new IllegalArgumentException(
205                     String.format("UICD CLI jar %s not found", mCliJar.getAbsolutePath()));
206         }
207 
208         // Load and process previous results
209         CollectingTestListener previousResults = this.parsePreviousResults();
210         if (previousResults != null) {
211             CLog.i("Loading previous results from %s", mPreviousResults);
212             this.loadPreviousResults(listener, previousResults);
213         }
214 
215         // Find test cases to execute
216         List<UiConductorTestCase> testCases = new ArrayList<>();
217         for (Map.Entry<String, File> entry : mTests.entries()) {
218             String key = entry.getKey();
219             File file = entry.getValue();
220             testCases.addAll(getTestCases(key, file));
221         }
222 
223         // Create work directory and copy binaries into it
224         if (mWorkDir == null) {
225             mWorkDir = createWorkDir().toFile();
226         }
227         mRunUtil = createRunUtil();
228         mRunUtil.setWorkingDir(mWorkDir);
229         for (File binary : mBinaries) {
230             Path copiedBinary = copyFile(binary.toPath(), mWorkDir.toPath());
231             copiedBinary.toFile().setExecutable(true);
232         }
233         mOutputDir = mWorkDir.toPath().resolve("output");
234 
235         // Execute test cases
236         for (UiConductorTestCase testCase : testCases) {
237             if (!shouldRunTestCase(testCase)) {
238                 CLog.d("Skipping %s", testCase.mDesc);
239                 continue;
240             }
241             // TODO(b/186141354): Revert to one module once ATS supports detailed proto results
242             long runStartTime = System.currentTimeMillis();
243             listener.testRunStarted(testCase.mDesc.toString(), 1);
244             runTestCase(listener, testCase, testInfo.getDevices());
245             listener.testRunEnded(System.currentTimeMillis() - runStartTime, Map.of());
246         }
247     }
248 
249     /** @return {@link IRunUtil} instance to use */
250     @VisibleForTesting
createRunUtil()251     IRunUtil createRunUtil() {
252         return new RunUtil();
253     }
254 
255     /** @return temporary working directory to use if none is provided */
createWorkDir()256     private Path createWorkDir() {
257         try {
258             return FileUtil.createTempDir(MODULE_NAME, CurrentInvocation.getWorkFolder()).toPath();
259         } catch (IOException e) {
260             throw new UncheckedIOException(e);
261         }
262     }
263 
264     /** @return true if the test case should be executed */
shouldRunTestCase(UiConductorTestCase testCase)265     private boolean shouldRunTestCase(UiConductorTestCase testCase) {
266         String testId = testCase.mDesc.toString();
267         if (mExcludeFilters.stream().anyMatch(testId::matches)) {
268             return false;
269         }
270         return mIncludeFilters.isEmpty() || mIncludeFilters.stream().anyMatch(testId::matches);
271     }
272 
273     /** Execute a test case using the UICD CLI and parses the result. */
runTestCase( ITestInvocationListener listener, UiConductorTestCase testCase, List<ITestDevice> devices)274     private void runTestCase(
275             ITestInvocationListener listener,
276             UiConductorTestCase testCase,
277             List<ITestDevice> devices) {
278         listener.testStarted(testCase.mDesc, System.currentTimeMillis());
279 
280         // Execute the UICD command and handle the result
281         String[] command = buildCommand(testCase, devices);
282         CLog.i("Running %s (command: %s)", testCase.mDesc, Arrays.asList(command));
283         CommandResult result = mRunUtil.runTimedCmd(mTestTimeout.toMillis(), command);
284         switch (result.getStatus()) {
285             case SUCCESS:
286                 CLog.i(
287                         "Command succeeded, stdout = [%s], stderr = [%s].",
288                         result.getStdout(), result.getStderr());
289                 Path resultFile = mOutputDir.resolve(testCase.mId).resolve(TEST_RESULT_PATH);
290                 verifyTestResultFile(listener, testCase, resultFile.toFile());
291                 break;
292             case FAILED:
293             case EXCEPTION:
294                 CLog.e(
295                         "Command failed, stdout = [%s], stderr = [%s].",
296                         result.getStdout(), result.getStderr());
297                 listener.testFailed(testCase.mDesc, "Command failed");
298                 break;
299             case TIMED_OUT:
300                 CLog.e(
301                         "Command timed out, stdout = [%s], stderr = [%s].",
302                         result.getStdout(), result.getStderr());
303                 listener.testFailed(testCase.mDesc, "Command timed out");
304                 break;
305         }
306 
307         listener.testEnded(testCase.mDesc, System.currentTimeMillis(), Map.of());
308     }
309 
310     /** Parse a test result file and report test failures. */
verifyTestResultFile( ITestInvocationListener listener, UiConductorTestCase testCase, File resultFile)311     private void verifyTestResultFile(
312             ITestInvocationListener listener, UiConductorTestCase testCase, File resultFile) {
313         if (!resultFile.isFile()) {
314             listener.testFailed(
315                     testCase.mDesc, String.format("Test result file %s not found", resultFile));
316             return;
317         }
318 
319         try {
320             String resultContent = FileUtil.readStringFromFile(resultFile);
321             List<String> errors = parseTestResultJson(new JSONObject(resultContent));
322             if (!errors.isEmpty()) {
323                 listener.testFailed(testCase.mDesc, String.join("\n", errors));
324             }
325         } catch (IOException | JSONException e) {
326             CLog.e("Failed to parse test result file", e);
327             listener.testFailed(
328                     testCase.mDesc,
329                     String.format("Failed to parse test result file: %s", e.getMessage()));
330         }
331         try (FileInputStreamSource inputStream = new FileInputStreamSource(resultFile)) {
332             listener.testLog(testCase.mId + "_result", LogDataType.TEXT, inputStream);
333         }
334     }
335 
336     /** Recursively parses the test result JSON, looking for failures. */
parseTestResultJson(JSONObject result)337     private List<String> parseTestResultJson(JSONObject result) {
338         if (result == null) {
339             return List.of();
340         }
341 
342         List<String> errors = new ArrayList<>();
343         JSONArray childrenResult = result.optJSONArray("childrenResult");
344         if (childrenResult != null) {
345             for (int i = 0; i < childrenResult.length(); i++) {
346                 errors.addAll(parseTestResultJson(childrenResult.optJSONObject(i)));
347             }
348         }
349         if ("FAIL".equalsIgnoreCase(result.optString("playStatus"))) {
350             String error =
351                     String.format(
352                             "%s (%s): %s",
353                             result.optString("actionId"),
354                             result.optString("content"),
355                             result.optString("validationDetails"));
356             errors.add(error);
357         }
358         return errors;
359     }
360 
361     /**
362      * Copy a file into a directory.
363      *
364      * @param srcFile file to copy
365      * @param destDir directory to copy into
366      * @return copied file
367      */
copyFile(Path srcFile, Path destDir)368     private Path copyFile(Path srcFile, Path destDir) {
369         try {
370             Files.createDirectories(destDir);
371             Path destFile = destDir.resolve(srcFile.getFileName());
372             return Files.copy(srcFile, destFile);
373         } catch (IOException e) {
374             throw new UncheckedIOException(e);
375         }
376     }
377 
378     /**
379      * Find all test cases in the specified file or directory.
380      *
381      * @param key test key to associate with test cases
382      * @param file file or directory to look in
383      * @return list of test cases
384      */
getTestCases(String key, File file)385     private List<UiConductorTestCase> getTestCases(String key, File file) {
386         if (!file.exists()) {
387             throw new IllegalArgumentException(
388                     String.format("Test file %s not found", file.getAbsolutePath()));
389         }
390         if (file.isDirectory()) {
391             try {
392                 // Find all nested regular files and use their relative paths as IDs
393                 Path dirPath = file.toPath().toAbsolutePath();
394                 try (Stream<Path> stream = Files.walk(dirPath)) {
395                     return stream.filter(Files::isRegularFile)
396                             .sorted()
397                             .map(
398                                     filePath -> {
399                                         String id =
400                                                 dirPath.getParent().relativize(filePath).toString();
401                                         return new UiConductorTestCase(id, key, filePath.toFile());
402                                     })
403                             .collect(Collectors.toList());
404                 }
405             } catch (IOException e) {
406                 throw new UncheckedIOException(e);
407             }
408         }
409         // Normal file, use filename as ID
410         return List.of(new UiConductorTestCase(file.getName(), key, file));
411     }
412 
413     /** Constructs the command to execute for a test case. */
buildCommand(UiConductorTestCase testCase, List<ITestDevice> devices)414     private String[] buildCommand(UiConductorTestCase testCase, List<ITestDevice> devices) {
415         List<String> command = new ArrayList<>();
416         command.add("java");
417         command.add("-jar");
418         command.add(mCliJar.getAbsolutePath());
419         // Add input file path
420         command.add(INPUT_OPTION);
421         command.add(testCase.mFile.getAbsolutePath());
422         // Add output directory path
423         command.add(OUTPUT_OPTION);
424         command.add(mOutputDir.resolve(testCase.mId).toString());
425         // Add play mode
426         command.add(MODE_OPTION);
427         command.add(mPlayMode.name());
428         // Add device serial numbers (comma separated list)
429         command.add(DEVICES_OPTION);
430         String serials =
431                 devices.stream().map(ITestDevice::getSerialNumber).collect(Collectors.joining(","));
432         command.add(serials);
433         // Add global variables if applicable
434         if (mGlobalVariables.containsKey(testCase.mKey)) {
435             command.add(GLOBAL_VARIABLE_OPTION);
436             command.add(String.join(",", mGlobalVariables.get(testCase.mKey)));
437         }
438         return command.toArray(new String[] {});
439     }
440 
441     /**
442      * Try to locate and parse an existing output file.
443      *
444      * @return listener containing the results or {@code null} if not found.
445      */
446     @Nullable
parsePreviousResults()447     private CollectingTestListener parsePreviousResults() {
448         if (mPreviousResults == null) {
449             return null;
450         }
451         if (!mPreviousResults.isFile()) {
452             throw new IllegalArgumentException(
453                     String.format(
454                             "Previous results %s not found", mPreviousResults.getAbsolutePath()));
455         }
456 
457         try {
458             TestRecordProto.TestRecord record = TestRecordProtoUtil.readFromFile(mPreviousResults);
459             return TestRecordInterpreter.interpreteRecord(record);
460         } catch (IOException e) {
461             throw new UncheckedIOException(e);
462         }
463     }
464 
465     /** Iterate over previous results to add them to the current run and exclude passed tests. */
loadPreviousResults( ITestInvocationListener listener, CollectingTestListener results)466     private void loadPreviousResults(
467             ITestInvocationListener listener, CollectingTestListener results) {
468         results.getMergedTestRunResults().stream()
469                 .filter(module -> module.getName().startsWith(MODULE_NAME + '#'))
470                 .forEach(
471                         module -> {
472                             // Found a previous result for this module, replay it
473                             Map<TestDescription, TestResult> tests = module.getTestResults();
474                             listener.testRunStarted(module.getName(), tests.size());
475                             tests.forEach(
476                                     (test, result) -> {
477                                         listener.testStarted(test, result.getStartTime());
478                                         if (result.getStatus() == TestStatus.FAILURE) {
479                                             listener.testFailed(test, result.getStackTrace());
480                                         } else {
481                                             // Only the PASSED and FAILURE test statuses are used,
482                                             // so exclude all non-FAILURE tests.
483                                             this.addExcludeFilter(test.toString());
484                                         }
485                                         listener.testEnded(test, result.getEndTime(), Map.of());
486                                     });
487                             listener.testRunEnded(module.getElapsedTime(), Map.of());
488                         });
489     }
490 
491     /** Writes results to a uicd_results.pb file which can be used for file-based retries. */
492     @OptionClass(alias = "uicd")
493     public static class ResultReporter extends FileProtoResultReporter {
494 
495         @Option(name = "output-path", description = "Output file path, can be used for retries")
496         private String mOutputPath = DEFAULT_OUTPUT_PATH;
497 
498         private File mOutputFile;
499 
500         @Override
processStartInvocation( TestRecordProto.TestRecord record, IInvocationContext context)501         public void processStartInvocation(
502                 TestRecordProto.TestRecord record, IInvocationContext context) {
503             mOutputFile = new File(mOutputPath + ".tmp").getAbsoluteFile();
504             setFileOutput(mOutputFile);
505             super.processStartInvocation(record, context);
506         }
507 
508         @Override
processFinalProto(TestRecordProto.TestRecord record)509         public void processFinalProto(TestRecordProto.TestRecord record) {
510             super.processFinalProto(record);
511             mOutputFile.renameTo(new File(mOutputPath));
512         }
513     }
514 }
515