blob: dafbfd5d8fb65a6accec54fbec7f248e8e584d6a [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.uicd.tests;
import com.android.ddmlib.testrunner.TestResult.TestStatus;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.OptionClass;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.invoker.IInvocationContext;
import com.android.tradefed.invoker.TestInformation;
import com.android.tradefed.invoker.logger.CurrentInvocation;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.result.CollectingTestListener;
import com.android.tradefed.result.FileInputStreamSource;
import com.android.tradefed.result.ITestInvocationListener;
import com.android.tradefed.result.LogDataType;
import com.android.tradefed.result.TestDescription;
import com.android.tradefed.result.TestResult;
import com.android.tradefed.result.proto.FileProtoResultReporter;
import com.android.tradefed.result.proto.TestRecordProto;
import com.android.tradefed.testtype.IRemoteTest;
import com.android.tradefed.testtype.ITestFilterReceiver;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.IRunUtil;
import com.android.tradefed.util.MultiMap;
import com.android.tradefed.util.RunUtil;
import com.android.tradefed.util.TestRecordInterpreter;
import com.android.tradefed.util.proto.TestRecordProtoUtil;
import com.google.common.annotations.VisibleForTesting;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.Nullable;
/**
* Runs pre-recorded Android UIConductor tests in Tradefed. Each provided JSON file is treated as a
* test case. Supports automatic retries, including file-based retries across invocations using
* {@link UiConductorTest.ResultReporter}. See XML configurations in res/config/uicd for examples.
*
* <p>See Also: https://github.com/google/android-uiconductor
* https://console.cloud.google.com/storage/browser/uicd-deps
*/
@OptionClass(alias = "uicd")
public class UiConductorTest implements IRemoteTest, ITestFilterReceiver {
static final String MODULE_NAME = UiConductorTest.class.getSimpleName();
static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(30L);
static final String DEFAULT_OUTPUT_PATH = "uicd_results.pb";
static final String INPUT_OPTION = "--input";
static final String OUTPUT_OPTION = "--output";
static final String DEVICES_OPTION = "--devices";
static final String MODE_OPTION = "--mode";
static final String GLOBAL_VARIABLE_OPTION = "--global_variable";
static final String TEST_RESULT_PATH = "result/action_execution_result";
/** Testing mode. */
public enum PlayMode {
SINGLE,
MULTIDEVICE,
PLAYALL,
}
/** Test case information, contains the test file and its metadata. */
private static class UiConductorTestCase {
private final String mId;
private final String mKey;
private final File mFile;
private final TestDescription mDesc;
private UiConductorTestCase(String id, String key, File file) {
mId = id;
mKey = key;
mFile = file;
mDesc = new TestDescription(MODULE_NAME, mId);
}
}
@Option(name = "work-dir", description = "Optional work directory to use")
private File mWorkDir;
@Option(
name = "uicd-cli-jar",
description = "UICD CLI jar to use when running tests",
mandatory = true)
private File mCliJar;
@Option(
name = "commandline-action-executable",
description = "Additional binaries needed by command line actions. Can be repeated.")
private Collection<File> mBinaries = new ArrayList<>();
@Option(
name = "global-variables",
description = "Global variable (uicd_key1=value1,uicd_key2=value2)")
private MultiMap<String, String> mGlobalVariables = new MultiMap<>();
@Option(name = "play-mode", description = "Play mode (SINGLE|MULTIDEVICE|PLAYALL)")
private PlayMode mPlayMode = PlayMode.SINGLE;
// Same key can have multiple test files because global-variables can be referenced using the
// that particular key and shared across different tests.
// Refer res/config/uicd/uiconductor-globalvariable-sample.xml for more information.
@Option(
name = "uicd-test",
description = "JSON test file or directory of JSON test files to run. Can be repeated.",
mandatory = true)
private MultiMap<String, File> mTests = new MultiMap<>();
@Option(name = "test-timeout", description = "Timeout for each test case")
private Duration mTestTimeout = DEFAULT_TIMEOUT;
@Option(name = "include-filter", description = "Regex filters used to find tests to include")
private Set<String> mIncludeFilters = new HashSet<>();
@Option(name = "exclude-filter", description = "Regex filters used to find tests to exclude")
private Set<String> mExcludeFilters = new HashSet<>();
@Option(name = "previous-results", description = "Previous output file to load when retrying")
private File mPreviousResults;
private IRunUtil mRunUtil;
private Path mOutputDir;
@Override
public void addIncludeFilter(String filter) {
mIncludeFilters.add(filter);
}
@Override
public void addAllIncludeFilters(Set<String> filters) {
mIncludeFilters.addAll(filters);
}
@Override
public void addExcludeFilter(String filter) {
mExcludeFilters.add(filter);
}
@Override
public void addAllExcludeFilters(Set<String> filters) {
mExcludeFilters.addAll(filters);
}
@Override
public Set<String> getIncludeFilters() {
return mIncludeFilters;
}
@Override
public Set<String> getExcludeFilters() {
return mExcludeFilters;
}
@Override
public void clearIncludeFilters() {
mIncludeFilters.clear();
}
@Override
public void clearExcludeFilters() {
mExcludeFilters.clear();
}
@Override
public void run(TestInformation testInfo, ITestInvocationListener listener)
throws DeviceNotAvailableException {
if (!mCliJar.isFile()) {
throw new IllegalArgumentException(
String.format("UICD CLI jar %s not found", mCliJar.getAbsolutePath()));
}
// Load and process previous results
CollectingTestListener previousResults = this.parsePreviousResults();
if (previousResults != null) {
CLog.i("Loading previous results from %s", mPreviousResults);
this.loadPreviousResults(listener, previousResults);
}
// Find test cases to execute
List<UiConductorTestCase> testCases = new ArrayList<>();
for (Map.Entry<String, File> entry : mTests.entries()) {
String key = entry.getKey();
File file = entry.getValue();
testCases.addAll(getTestCases(key, file));
}
// Create work directory and copy binaries into it
if (mWorkDir == null) {
mWorkDir = createWorkDir().toFile();
}
mRunUtil = createRunUtil();
mRunUtil.setWorkingDir(mWorkDir);
for (File binary : mBinaries) {
Path copiedBinary = copyFile(binary.toPath(), mWorkDir.toPath());
copiedBinary.toFile().setExecutable(true);
}
mOutputDir = mWorkDir.toPath().resolve("output");
// Execute test cases
for (UiConductorTestCase testCase : testCases) {
if (!shouldRunTestCase(testCase)) {
CLog.d("Skipping %s", testCase.mDesc);
continue;
}
// TODO(b/186141354): Revert to one module once ATS supports detailed proto results
long runStartTime = System.currentTimeMillis();
listener.testRunStarted(testCase.mDesc.toString(), 1);
runTestCase(listener, testCase, testInfo.getDevices());
listener.testRunEnded(System.currentTimeMillis() - runStartTime, Map.of());
}
}
/** @return {@link IRunUtil} instance to use */
@VisibleForTesting
IRunUtil createRunUtil() {
return new RunUtil();
}
/** @return temporary working directory to use if none is provided */
private Path createWorkDir() {
try {
return FileUtil.createTempDir(MODULE_NAME, CurrentInvocation.getWorkFolder()).toPath();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/** @return true if the test case should be executed */
private boolean shouldRunTestCase(UiConductorTestCase testCase) {
String testId = testCase.mDesc.toString();
if (mExcludeFilters.stream().anyMatch(testId::matches)) {
return false;
}
return mIncludeFilters.isEmpty() || mIncludeFilters.stream().anyMatch(testId::matches);
}
/** Execute a test case using the UICD CLI and parses the result. */
private void runTestCase(
ITestInvocationListener listener,
UiConductorTestCase testCase,
List<ITestDevice> devices) {
listener.testStarted(testCase.mDesc, System.currentTimeMillis());
// Execute the UICD command and handle the result
String[] command = buildCommand(testCase, devices);
CLog.i("Running %s (command: %s)", testCase.mDesc, Arrays.asList(command));
CommandResult result = mRunUtil.runTimedCmd(mTestTimeout.toMillis(), command);
switch (result.getStatus()) {
case SUCCESS:
CLog.i(
"Command succeeded, stdout = [%s], stderr = [%s].",
result.getStdout(), result.getStderr());
Path resultFile = mOutputDir.resolve(testCase.mId).resolve(TEST_RESULT_PATH);
verifyTestResultFile(listener, testCase, resultFile.toFile());
break;
case FAILED:
case EXCEPTION:
CLog.e(
"Command failed, stdout = [%s], stderr = [%s].",
result.getStdout(), result.getStderr());
listener.testFailed(testCase.mDesc, "Command failed");
break;
case TIMED_OUT:
CLog.e(
"Command timed out, stdout = [%s], stderr = [%s].",
result.getStdout(), result.getStderr());
listener.testFailed(testCase.mDesc, "Command timed out");
break;
}
listener.testEnded(testCase.mDesc, System.currentTimeMillis(), Map.of());
}
/** Parse a test result file and report test failures. */
private void verifyTestResultFile(
ITestInvocationListener listener, UiConductorTestCase testCase, File resultFile) {
if (!resultFile.isFile()) {
listener.testFailed(
testCase.mDesc, String.format("Test result file %s not found", resultFile));
return;
}
try {
String resultContent = FileUtil.readStringFromFile(resultFile);
List<String> errors = parseTestResultJson(new JSONObject(resultContent));
if (!errors.isEmpty()) {
listener.testFailed(testCase.mDesc, String.join("\n", errors));
}
} catch (IOException | JSONException e) {
CLog.e("Failed to parse test result file", e);
listener.testFailed(
testCase.mDesc,
String.format("Failed to parse test result file: %s", e.getMessage()));
}
try (FileInputStreamSource inputStream = new FileInputStreamSource(resultFile)) {
listener.testLog(testCase.mId + "_result", LogDataType.TEXT, inputStream);
}
}
/** Recursively parses the test result JSON, looking for failures. */
private List<String> parseTestResultJson(JSONObject result) {
if (result == null) {
return List.of();
}
List<String> errors = new ArrayList<>();
JSONArray childrenResult = result.optJSONArray("childrenResult");
if (childrenResult != null) {
for (int i = 0; i < childrenResult.length(); i++) {
errors.addAll(parseTestResultJson(childrenResult.optJSONObject(i)));
}
}
if ("FAIL".equalsIgnoreCase(result.optString("playStatus"))) {
String error =
String.format(
"%s (%s): %s",
result.optString("actionId"),
result.optString("content"),
result.optString("validationDetails"));
errors.add(error);
}
return errors;
}
/**
* Copy a file into a directory.
*
* @param srcFile file to copy
* @param destDir directory to copy into
* @return copied file
*/
private Path copyFile(Path srcFile, Path destDir) {
try {
Files.createDirectories(destDir);
Path destFile = destDir.resolve(srcFile.getFileName());
return Files.copy(srcFile, destFile);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Find all test cases in the specified file or directory.
*
* @param key test key to associate with test cases
* @param file file or directory to look in
* @return list of test cases
*/
private List<UiConductorTestCase> getTestCases(String key, File file) {
if (!file.exists()) {
throw new IllegalArgumentException(
String.format("Test file %s not found", file.getAbsolutePath()));
}
if (file.isDirectory()) {
try {
// Find all nested regular files and use their relative paths as IDs
Path dirPath = file.toPath().toAbsolutePath();
try (Stream<Path> stream = Files.walk(dirPath)) {
return stream.filter(Files::isRegularFile)
.sorted()
.map(
filePath -> {
String id =
dirPath.getParent().relativize(filePath).toString();
return new UiConductorTestCase(id, key, filePath.toFile());
})
.collect(Collectors.toList());
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
// Normal file, use filename as ID
return List.of(new UiConductorTestCase(file.getName(), key, file));
}
/** Constructs the command to execute for a test case. */
private String[] buildCommand(UiConductorTestCase testCase, List<ITestDevice> devices) {
List<String> command = new ArrayList<>();
command.add("java");
command.add("-jar");
command.add(mCliJar.getAbsolutePath());
// Add input file path
command.add(INPUT_OPTION);
command.add(testCase.mFile.getAbsolutePath());
// Add output directory path
command.add(OUTPUT_OPTION);
command.add(mOutputDir.resolve(testCase.mId).toString());
// Add play mode
command.add(MODE_OPTION);
command.add(mPlayMode.name());
// Add device serial numbers (comma separated list)
command.add(DEVICES_OPTION);
String serials =
devices.stream().map(ITestDevice::getSerialNumber).collect(Collectors.joining(","));
command.add(serials);
// Add global variables if applicable
if (mGlobalVariables.containsKey(testCase.mKey)) {
command.add(GLOBAL_VARIABLE_OPTION);
command.add(String.join(",", mGlobalVariables.get(testCase.mKey)));
}
return command.toArray(new String[] {});
}
/**
* Try to locate and parse an existing output file.
*
* @return listener containing the results or {@code null} if not found.
*/
@Nullable
private CollectingTestListener parsePreviousResults() {
if (mPreviousResults == null) {
return null;
}
if (!mPreviousResults.isFile()) {
throw new IllegalArgumentException(
String.format(
"Previous results %s not found", mPreviousResults.getAbsolutePath()));
}
try {
TestRecordProto.TestRecord record = TestRecordProtoUtil.readFromFile(mPreviousResults);
return TestRecordInterpreter.interpreteRecord(record);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/** Iterate over previous results to add them to the current run and exclude passed tests. */
private void loadPreviousResults(
ITestInvocationListener listener, CollectingTestListener results) {
results.getMergedTestRunResults().stream()
.filter(module -> module.getName().startsWith(MODULE_NAME + '#'))
.forEach(
module -> {
// Found a previous result for this module, replay it
Map<TestDescription, TestResult> tests = module.getTestResults();
listener.testRunStarted(module.getName(), tests.size());
tests.forEach(
(test, result) -> {
listener.testStarted(test, result.getStartTime());
if (result.getStatus() == TestStatus.FAILURE) {
listener.testFailed(test, result.getStackTrace());
} else {
// Only the PASSED and FAILURE test statuses are used,
// so exclude all non-FAILURE tests.
this.addExcludeFilter(test.toString());
}
listener.testEnded(test, result.getEndTime(), Map.of());
});
listener.testRunEnded(module.getElapsedTime(), Map.of());
});
}
/** Writes results to a uicd_results.pb file which can be used for file-based retries. */
@OptionClass(alias = "uicd")
public static class ResultReporter extends FileProtoResultReporter {
@Option(name = "output-path", description = "Output file path, can be used for retries")
private String mOutputPath = DEFAULT_OUTPUT_PATH;
private File mOutputFile;
@Override
public void processStartInvocation(
TestRecordProto.TestRecord record, IInvocationContext context) {
mOutputFile = new File(mOutputPath + ".tmp").getAbsoluteFile();
setFileOutput(mOutputFile);
super.processStartInvocation(record, context);
}
@Override
public void processFinalProto(TestRecordProto.TestRecord record) {
super.processFinalProto(record);
mOutputFile.renameTo(new File(mOutputPath));
}
}
}