blob: d32be5619fb65076be90399f91fefa136d822804 [file] [log] [blame]
/*
* Copyright (C) 2020 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.tradefed.testtype;
import com.android.tradefed.config.Option;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.invoker.TestInformation;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
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.util.AbiUtils;
import com.android.tradefed.util.ArrayUtil;
import com.android.tradefed.util.CommandResult;
import com.android.tradefed.util.CommandStatus;
import com.android.tradefed.util.FileUtil;
import com.android.tradefed.util.RunUtil;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import difflib.DiffUtils;
import difflib.Patch;
/** A test runner to run ART run-tests. */
public class ArtRunTest implements IRemoteTest, IAbiReceiver, ITestFilterReceiver {
private static final String RUNTEST_TAG = "ArtRunTest";
private static final Path ART_APEX_PATH = Paths.get("/apex", "com.android.art");
private static final String DALVIKVM_CMD =
"dalvikvm|#BITNESS#| -classpath |#CLASSPATH#| |#MAINCLASS#|";
// Name of the Checker Python Archive (PAR) file.
public static final String CHECKER_PAR_FILENAME = "art-run-test-checker";
private static final long CHECKER_TIMEOUT_MS = 30 * 1000;
@Option(
name = "test-timeout",
description =
"The max time in ms for an art run-test to "
+ "run. Test run will be aborted if any test takes longer.",
isTimeVal = true)
private long mMaxTestTimeMs = 1 * 60 * 1000;
@Option(name = "run-test-name", description = "The name to use when reporting results.")
private String mRunTestName;
@Option(name = "classpath", description = "Holds the paths to search when loading tests.")
private List<String> mClasspath = new ArrayList<>();
private ITestDevice mDevice = null;
private IAbi mAbi = null;
private final Set<String> mIncludeFilters = new LinkedHashSet<>();
private final Set<String> mExcludeFilters = new LinkedHashSet<>();
/** {@inheritDoc} */
@Override
public void setAbi(IAbi abi) {
mAbi = abi;
}
@Override
public IAbi getAbi() {
return mAbi;
}
/** {@inheritDoc} */
@Override
public void addIncludeFilter(String filter) {
mIncludeFilters.add(filter);
}
/** {@inheritDoc} */
@Override
public void addAllIncludeFilters(Set<String> filters) {
mIncludeFilters.addAll(filters);
}
/** {@inheritDoc} */
@Override
public void addExcludeFilter(String filter) {
mExcludeFilters.add(filter);
}
/** {@inheritDoc} */
@Override
public void addAllExcludeFilters(Set<String> filters) {
mExcludeFilters.addAll(filters);
}
/** {@inheritDoc} */
@Override
public Set<String> getIncludeFilters() {
return mIncludeFilters;
}
/** {@inheritDoc} */
@Override
public Set<String> getExcludeFilters() {
return mExcludeFilters;
}
/** {@inheritDoc} */
@Override
public void clearIncludeFilters() {
mIncludeFilters.clear();
}
/** {@inheritDoc} */
@Override
public void clearExcludeFilters() {
mExcludeFilters.clear();
}
/** {@inheritDoc} */
@Override
public void run(TestInformation testInfo, ITestInvocationListener listener)
throws DeviceNotAvailableException {
mDevice = testInfo.getDevice();
if (mDevice == null) {
throw new IllegalArgumentException("Device has not been set.");
}
if (mAbi == null) {
throw new IllegalArgumentException("ABI has not been set.");
}
if (mRunTestName == null) {
throw new IllegalArgumentException("Run-test name has not been set.");
}
if (mClasspath.isEmpty()) {
throw new IllegalArgumentException("Classpath is empty.");
}
runArtTest(testInfo, listener);
}
/**
* Run a single ART run-test (on device).
*
* @param listener The {@link ITestInvocationListener} object associated to the executed test
* @throws DeviceNotAvailableException If there was a problem communicating with the device.
*/
void runArtTest(TestInformation testInfo, ITestInvocationListener listener)
throws DeviceNotAvailableException {
String abi = mAbi.getName();
String runName = String.format("%s_%s", RUNTEST_TAG, abi);
TestDescription testId = new TestDescription(runName, mRunTestName);
if (shouldSkipCurrentTest(testId)) {
return;
}
String deviceSerialNumber = mDevice.getSerialNumber();
CLog.i("Running ArtRunTest %s on %s", mRunTestName, deviceSerialNumber);
String testCmd = DALVIKVM_CMD;
testCmd = testCmd.replace("|#BITNESS#|", AbiUtils.getBitness(abi));
testCmd = testCmd.replace("|#CLASSPATH#|", ArrayUtil.join(File.pathSeparator, mClasspath));
// TODO: Turn this into an an option of the `ArtRunTest` class?
testCmd = testCmd.replace("|#MAINCLASS#|", "Main");
CLog.d("About to run run-test command: `%s`", testCmd);
// Note: We only run one test at the moment.
int testCount = 1;
listener.testRunStarted(runName, testCount);
listener.testStarted(testId);
try {
// TODO: The "run" step should be configurable, as is the case in current ART
// `run-test` scripts).
// Execute the test on device.
CommandResult testResult =
mDevice.executeShellV2Command(
testCmd, mMaxTestTimeMs, TimeUnit.MILLISECONDS, /* retryAttempts */ 0);
if (testResult.getStatus() != CommandStatus.SUCCESS) {
String message =
String.format(
"Test command execution failed with status %s: %s",
testResult.getStatus(), testResult);
CLog.e(message);
listener.testFailed(testId, message);
return;
}
Integer exitCode = testResult.getExitCode();
CLog.v("`%s` on %s returned exit code: %d", testCmd, deviceSerialNumber, exitCode);
String actualStdoutText = testResult.getStdout();
CLog.v("`%s` on %s returned stdout: %s", testCmd, deviceSerialNumber, actualStdoutText);
String actualStderrText = testResult.getStderr();
CLog.v("`%s` on %s returned stderr: %s", testCmd, deviceSerialNumber, actualStderrText);
// TODO: The "check" step should be configurable, as is the case in current ART
// `run-test` scripts).
// List of encountered errors during the test.
List<String> errors = new ArrayList<>();
// Check the test's exit code.
Optional<String> exitCodeError = checkExitCode(exitCode);
exitCodeError.ifPresent(e -> errors.add(e));
// Check the test's standard output.
Optional<String> stdoutError =
checkTestOutput(
testInfo,
actualStdoutText,
/* outputShortName */ "stdout",
/* outputPrettyName */ "standard output");
stdoutError.ifPresent(e -> errors.add(e));
// Check the test's standard error.
Optional<String> stderrError =
checkTestOutput(
testInfo,
actualStderrText,
/* outputShortName */ "stderr",
/* outputPrettyName */ "standard error");
stderrError.ifPresent(e -> errors.add(e));
// If the test us a Checker test, run Checker and check its output.
if (mRunTestName.contains("-checker-")) {
Optional<String> checkerError = executeCheckerTest(testInfo, listener);
checkerError.ifPresent(e -> errors.add(e));
}
// Process potential errors.
if (!errors.isEmpty()) {
String errorMessage = String.join("\n", errors);
listener.testFailed(testId, errorMessage);
}
} finally {
HashMap<String, Metric> emptyTestMetrics = new HashMap<>();
listener.testEnded(testId, emptyTestMetrics);
HashMap<String, Metric> emptyTestRunMetrics = new HashMap<>();
// TODO: Pass an actual value as `elapsedTimeMillis` argument.
listener.testRunEnded(/* elapsedTimeMillis*/ 0, emptyTestRunMetrics);
}
}
/**
* Check the exit code returned by a test command.
*
* @param exitCode The exit code returned by the test command
* @return An optional error message, empty if the test exit code indicated success
*/
protected Optional<String> checkExitCode(Integer exitCode) {
if (exitCode != 0) {
String errorMessage =
String.format("Test `%s` exited with code %d", mRunTestName, exitCode);
CLog.i(errorMessage);
return Optional.of(errorMessage);
}
return Optional.empty();
}
/**
* Check an output produced by a test command.
*
* <p>Used to check the standard output and the standard error of a test.
*
* @param testInfo The {@link TestInformation} object associated to the executed test
* @param actualOutputText The output produced by the test
* @param outputShortName The short name of the output channel
* @param outputPrettyName A prettier name for the output channel, used in error messages
* @return An optional error message, empty if the checked output is valid
*/
protected Optional<String> checkTestOutput(
TestInformation testInfo,
String actualOutputText,
String outputShortName,
String outputPrettyName) {
final String expectedFileName = String.format("expected-%s.txt", outputShortName);
final String actualFileName = outputShortName;
if (actualOutputText == null) {
String errorMessage =
String.format(
"No %s received to compare to for test `%s`",
outputPrettyName, mRunTestName);
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
try {
String expectedOutputFileName = String.format("%s-%s", mRunTestName, expectedFileName);
File expectedOutputFile =
testInfo.getDependencyFile(expectedOutputFileName, /* targetFirst */ true);
CLog.i(
"Found expected %s for run-test `%s`: `%s`",
outputPrettyName, mRunTestName, expectedOutputFile);
String expectedOutputText = FileUtil.readStringFromFile(expectedOutputFile);
if (!actualOutputText.equals(expectedOutputText)) {
// Produce a unified diff output for the error message.
String diff =
computeDiff(
expectedOutputText,
actualOutputText,
expectedFileName,
actualFileName);
String errorMessage =
String.format(
"The actual %s does not match the expected %s for test `%s`:\n%s",
outputPrettyName, outputPrettyName, mRunTestName, diff);
CLog.i(errorMessage);
return Optional.of(errorMessage);
}
} catch (IOException ioe) {
String errorMessage =
String.format(
"I/O error while accessing expected %s for test `%s`: %s",
outputPrettyName, mRunTestName, ioe);
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
return Optional.empty();
}
/**
* Execute a Checker test and check its output.
*
* <p>Checker tests are additional tests included in some ART run-tests, written as annotations
* in the comments of a test's source files, and used to verify ART's compiler.
*
* @param testInfo The {@link TestInformation} object associated to the executed test
* @param listener The {@link ITestInvocationListener} object associated to the executed test
* @return An optional error message, empty if the Checker test succeeded
*/
protected Optional<String> executeCheckerTest(
TestInformation testInfo, ITestInvocationListener listener)
throws DeviceNotAvailableException {
// TODO: Encapsulate the device temp dir creation logic in its own method.
String tmpCheckerDir =
String.format("/data/local/tmp/%s", mRunTestName.replaceAll("/", "-"));
String mkdirCmd = String.format("mkdir -p \"%s\"", tmpCheckerDir);
CommandResult mkdirResult = mDevice.executeShellV2Command(mkdirCmd);
if (mkdirResult.getStatus() != CommandStatus.SUCCESS) {
String errorMessage =
String.format(
"Cannot create directory `%s` on device", mkdirResult.getStderr());
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
String cfgPath = tmpCheckerDir + "/graph.cfg";
String oatPath = tmpCheckerDir + "/output.oat";
String abi = mAbi.getName();
String dex2oatBinary = "dex2oat" + AbiUtils.getBitness(abi);
Path dex2oatPath = Paths.get(ART_APEX_PATH.toString(), "bin", dex2oatBinary);
String dex2oatCmd =
String.format(
"%s --dex-file=%s --oat-file=%s --dump-cfg=%s -j1",
dex2oatPath, mClasspath.get(0), oatPath, cfgPath);
CommandResult dex2oatResult = mDevice.executeShellV2Command(dex2oatCmd);
if (dex2oatResult.getStatus() != CommandStatus.SUCCESS) {
String errorMessage =
String.format("Error while running dex2oat: %s", dex2oatResult.getStderr());
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
File runTestDir;
try {
runTestDir =
Files.createTempDirectory(testInfo.dependenciesFolder().toPath(), mRunTestName)
.toFile();
} catch (IOException e) {
String errorMessage = String.format("I/O error while creating test dir: %s", e);
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
File localCfgPath = new File(runTestDir, "graph.cfg");
if (localCfgPath.isFile()) {
localCfgPath.delete();
}
if (!mDevice.pullFile(cfgPath, localCfgPath)) {
String errorMessage = "Cannot pull CFG file from the device";
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
File tempJar = new File(runTestDir, "temp.jar");
if (!mDevice.pullFile(mClasspath.get(0), tempJar)) {
String errorMessage = "Cannot pull JAR file from the device";
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
try {
extractSourcesFromJar(runTestDir, tempJar);
} catch (IOException e) {
String errorMessage = String.format("Error unpacking test JAR file: %s", e);
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
String checkerArch = AbiUtils.getArchForAbi(mAbi.getName()).toUpperCase();
File checkerBinary = getCheckerBinaryPath(testInfo);
String[] checkerCommandLine = {
checkerBinary.getAbsolutePath(),
"--no-print-cfg",
"-q",
"--arch=" + checkerArch,
localCfgPath.getAbsolutePath(),
runTestDir.getAbsolutePath()
};
try {
String checkerOutput = runAndCollectStderr(checkerCommandLine);
if (checkerOutput != null && !checkerOutput.isEmpty()) {
String errorMessage = "Checker test failed:\n" + checkerOutput;
listener.testLog(
"graph.cfg", LogDataType.CFG, new FileInputStreamSource(localCfgPath));
CLog.i(errorMessage);
return Optional.of(errorMessage);
}
} catch (RuntimeException e) {
String errorMessage =
String.format("Runtime exception while starting Checker process: %s", e);
CLog.e(errorMessage);
return Optional.of(errorMessage);
}
FileUtil.recursiveDelete(runTestDir);
return Optional.empty();
}
/** Find the Checker binary (Python Archive). */
protected File getCheckerBinaryPath(TestInformation testInfo) {
File checkerBinary;
try {
checkerBinary =
testInfo.getDependencyFile(CHECKER_PAR_FILENAME, /* targetFirst */ false);
} catch (FileNotFoundException e) {
throw new RuntimeException(
String.format("Couldn't find Checker binary file `%s`", CHECKER_PAR_FILENAME));
}
checkerBinary.setExecutable(true);
return checkerBinary;
}
protected String runAndCollectStderr(String[] checkerCommandLine) {
CommandResult result = RunUtil.getDefault().runTimedCmd(CHECKER_TIMEOUT_MS,
checkerCommandLine);
if (result.getStatus() != CommandStatus.SUCCESS) {
if (result.getStatus() == CommandStatus.TIMED_OUT) {
throw new RuntimeException("Checker timed out");
}
throw new RuntimeException("Error running Checker\n" + result.getStderr());
}
return result.getStderr();
}
/**
* Extract src directory from given jar file to given directory
*/
protected void extractSourcesFromJar(File runTestDir, File jar) throws IOException {
try (ZipFile archive = new ZipFile(jar)) {
File srcFile = new File(runTestDir, "src");
if (srcFile.exists()) {
FileUtil.recursiveDelete(srcFile);
}
List<? extends ZipEntry> entries =
archive.stream()
.sorted(Comparator.comparing(ZipEntry::getName))
.collect(Collectors.toList());
for (ZipEntry entry : entries) {
if (entry.getName().startsWith("src")) {
Path entryDest = runTestDir.toPath().resolve(entry.getName());
if (entry.isDirectory()) {
Files.createDirectory(entryDest);
} else {
Files.copy(archive.getInputStream(entry), entryDest);
}
}
}
}
}
/**
* Check if current test should be skipped.
*
* @param description The test in progress.
* @return true if the test should be skipped.
*/
private boolean shouldSkipCurrentTest(TestDescription description) {
// Force to skip any test not listed in include filters, or listed in exclude filters.
// exclude filters have highest priority.
String testName = description.getTestName();
String descString = description.toString();
if (mExcludeFilters.contains(testName) || mExcludeFilters.contains(descString)) {
return true;
}
if (!mIncludeFilters.isEmpty()) {
return !mIncludeFilters.contains(testName) && !mIncludeFilters.contains(descString);
}
return false;
}
/**
* Compute the difference between expected and actual outputs as a unified diff.
*
* @param expected The expected output
* @param actual The actual output
* @param expectedFileName The name of the expected output file name (used in diff header)
* @param actualFileName The name of the actual output file name (used in diff header)
* @return The unified diff between the expected and actual outputs
*/
private String computeDiff(
String expected, String actual, String expectedFileName, String actualFileName) {
List<String> expectedLines = Arrays.asList(expected.split("\\r?\\n"));
List<String> actualLines = Arrays.asList(actual.split("\\r?\\n"));
Patch<String> diff = DiffUtils.diff(expectedLines, actualLines);
List<String> unifiedDiff =
DiffUtils.generateUnifiedDiff(
expectedFileName, actualFileName, expectedLines, diff, 3);
StringBuilder diffOutput = new StringBuilder();
for (String delta : unifiedDiff) {
diffOutput.append(delta).append('\n');
}
return diffOutput.toString();
}
}