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