blob: bcc5cbdb016f3de4b3bf36cc3061b1ef0784da6c [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.ddmlib.testrunner;
import static com.google.common.base.Strings.isNullOrEmpty;
import com.android.commands.am.InstrumentationData;
import com.android.ddmlib.Log;
import com.google.common.collect.ImmutableMap;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.ByteArrayOutputStream;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Parses the 'protoStd output mode' results of an instrumentation test run from shell and informs a
* ITestRunListener of the results.
*
* <p>Am instrument command with "-m" option outputs test execution status in binary protobuf format
* incrementally. The output protobuf message is {@link InstrumentationData.Session}, which has two
* fields: 1) a repeated field of {@link InstrumentationData.TestStatus}, 2) {@link
* InstrumentationData.SessionStatus}. The am instrument command outputs test status message
* before/after each test execution. {@link #addOutput} is invoked with a {@code data} argument
* which is a serialized bytes of {@code TestStatus}(es). When all tests are done, the command
* outputs {@code SessionStatus} at last.
*
* <p>See
* https://cs.android.com/android/platform/superproject/+/master:frameworks/base/cmds/am/proto/instrumentation_data.proto
* for a complete protobuf definition.
*
* <p>{@code InstrumentationProtoResultParser} is a state-machine and states are defined in {@link
* InstrumentationProtoResultParserState}. The state begins with {@link
* InstrumentationProtoResultParserState#NOT_STARTED}. When it sees a first test case status, it
* moves to {@link InstrumentationProtoResultParserState#RUNNING}. The state changes to {@link
* InstrumentationProtoResultParserState#FINISHED} if all tests are done or {@link
* InstrumentationProtoResultParserState#CANCELLED} if it is requested by calling {@link #cancel}.
*
* <p>Every time a new test status is available, it will be informed to the registered listeners.
*/
public class InstrumentationProtoResultParser implements IInstrumentationResultParser {
/** An internal state of {@link InstrumentationProtoResultParser}. */
private enum InstrumentationProtoResultParserState {
/** Test run is not started. */
NOT_STARTED(false),
/** Test is currently running. */
RUNNING(false),
/** All test executions are completed. */
FINISHED(true),
/** Test run has been cancelled. */
CANCELLED(true);
private final boolean mIsTerminalState;
InstrumentationProtoResultParserState(boolean isTerminalState) {
mIsTerminalState = isTerminalState;
}
public boolean isTerminalState() {
return mIsTerminalState;
}
}
/** Represents a status of a single test case execution. */
private static class TestStatus {
/**
* The result code of this test case.
*
* @see IInstrumentationResultParser.StatusCodes
*/
private int mTestResultCode;
/** The logcat output emitted during this test case */
private StringBuilder mLogcat = new StringBuilder();
/** A maximum length of logcat message to be stored and reported. */
private static final int MAX_LOGCAT_LENGTH = 10000;
/**
* The test status metrics emitted during the execution of the test case by {@code
* android.app.Instrumentation#sendStatus}. The insertion order is preserved unless the test
* emits a same key multiple times. Note that standard keys defined in {@link
* IInstrumentationResultParser.StatusKeys} should be filtered out of this Map.
*/
private final LinkedHashMap<String, String> mTestMetrics = new LinkedHashMap<>();
public TestStatus(int testResultCode) {
mTestResultCode = testResultCode;
}
public void setTestResultCode(int testResultCode) {
mTestResultCode = testResultCode;
}
public int getTestResultCode() {
return mTestResultCode;
}
public void appendLogcat(String logcat) {
if (mLogcat.length() >= MAX_LOGCAT_LENGTH) {
return;
}
if (mLogcat.length() + logcat.length() < MAX_LOGCAT_LENGTH) {
mLogcat.append(logcat);
} else {
mLogcat.append(logcat.subSequence(0, MAX_LOGCAT_LENGTH - mLogcat.length()));
}
}
public void clearLogcat() {
mLogcat = new StringBuilder();
}
public String getLogcat() {
return mLogcat.toString();
}
public void putTestMetrics(String key, String value) {
mTestMetrics.put(key, value);
}
public void putAllTestMetrics(Map<String, String> testMetrics) {
mTestMetrics.putAll(testMetrics);
}
public Map<String, String> getTestMetrics() {
return ImmutableMap.copyOf(mTestMetrics);
}
}
/** The tag to be used for logging. */
private static final String LOG_TAG = "InstrumentationProtoResultParser";
private final String mRunName;
private final Collection<ITestRunListener> mListeners;
/** True if current test run has been canceled by user. */
private InstrumentationProtoResultParserState mState =
InstrumentationProtoResultParserState.NOT_STARTED;
/**
* A received byte array to be processed and translated into {@link InstrumentationData.Session}
* message.
*/
private ByteArrayOutputStream mPendingData = new ByteArrayOutputStream();
/** The latest test statuses. Uses LinkedHashMap to preserve the insertion order. */
private final LinkedHashMap<TestIdentifier, TestStatus> mTestStatuses = new LinkedHashMap<>();
/**
* A regex patter to be used for finding test execution elapsed time from session output string.
*/
private final Pattern mTimePattern = Pattern.compile("Time: \\s*([\\d\\,]*[\\d\\.]+)");
/**
* Constructs {@link InstrumentationProtoResultParser}.
*
* @param runName the test run name to provide to {@link ITestRunListener#testRunStarted}
* @param listeners informed of test results as the tests are executing
*/
public InstrumentationProtoResultParser(
String runName, Collection<ITestRunListener> listeners) {
mRunName = runName;
mListeners = listeners;
}
/**
* This method is called every time some new data is available.
*
* @param data a serialized data of {@link InstrumentationData.Session} message. If {@code data}
* is an incomplete chunk, they are added into an internal buffer and will be processed in
* the next {@link #addOutput} call.
* @param offset an offset of the new data stored in {@code data}
* @param length bytes of a new data in {@code data} to be processed.
*/
@Override
public void addOutput(byte[] data, int offset, int length) {
if (mState.isTerminalState()) {
return;
}
mPendingData.write(data, offset, length);
try {
InstrumentationData.Session session =
InstrumentationData.Session.parseFrom(mPendingData.toByteArray());
mPendingData.reset();
updateState(session);
} catch (InvalidProtocolBufferException e) {
// InvalidProtocolBufferException may happen if a given new output data is incomplete.
// Let's just skip updating mSession and wait for incoming data.
}
}
private void updateState(InstrumentationData.Session session) {
for (InstrumentationData.TestStatus status : session.getTestStatusList()) {
// Notify test run started if this is the initial test state update.
if (mState == InstrumentationProtoResultParserState.NOT_STARTED) {
final int numTests =
status.getResults()
.getEntriesList()
.stream()
.filter(entry -> entry.getKey().equals(StatusKeys.NUMTESTS))
.map(entry -> entry.getValueInt())
.findFirst()
.orElse(0);
if (numTests == 0) {
return;
}
mState = InstrumentationProtoResultParserState.RUNNING;
for (ITestRunListener listener : mListeners) {
listener.testRunStarted(mRunName, numTests);
}
}
String testClassName = "";
String testMethodName = "";
int currentTestIndex = -1;
String stackTrace = "";
LinkedHashMap<String, String> testMetrics = new LinkedHashMap<>();
for (InstrumentationData.ResultsBundleEntry entry :
status.getResults().getEntriesList()) {
switch (entry.getKey()) {
case StatusKeys.CLASS:
testClassName = entry.getValueString();
break;
case StatusKeys.TEST:
testMethodName = entry.getValueString();
break;
case StatusKeys.STACK:
stackTrace = entry.getValueString();
break;
case StatusKeys.CURRENT:
currentTestIndex = entry.getValueInt();
break;
default:
if (!StatusKeys.KNOWN_KEYS.contains(entry.getKey())) {
testMetrics.put(
entry.getKey(), getResultsEntryBundleValueInString(entry));
}
}
}
// If both test class name and method name are missing, assume the previous test is
// the current one. This happens if your test sends custom test status by
// Instrumentation.sendStatus() method.
// Also, we should ignore reported test ResultCode from sendStatus() call because
// it is often misused and Activity.RESULT_OK (= -1) is set. -1 means ERROR here
// which causes ddmlib to fail.
Optional<Integer> resultCodeOverride = Optional.empty();
if (isNullOrEmpty(testClassName) && isNullOrEmpty(testMethodName)) {
Optional<Map.Entry<TestIdentifier, TestStatus>> previousTestStatus =
mTestStatuses.entrySet().stream().reduce((first, second) -> second);
if (previousTestStatus.isPresent()) {
testClassName = previousTestStatus.get().getKey().getClassName();
testMethodName = previousTestStatus.get().getKey().getTestName();
currentTestIndex = previousTestStatus.get().getKey().getTestIndex();
resultCodeOverride =
Optional.of(previousTestStatus.get().getValue().mTestResultCode);
} else {
testClassName = "";
testMethodName = "";
}
}
if (!isNullOrEmpty(testClassName) && !isNullOrEmpty(testMethodName)) {
updateTestState(
testClassName,
testMethodName,
currentTestIndex,
resultCodeOverride.orElse(status.getResultCode()),
status.getLogcat(),
stackTrace,
testMetrics);
}
}
if (session.hasSessionStatus()) {
mState = InstrumentationProtoResultParserState.FINISHED;
LinkedHashMap<String, String> testRunMetrics = new LinkedHashMap<>();
switch (session.getSessionStatus().getStatusCode()) {
// System server is crashed during the test execution.
case SESSION_ABORTED:
{
String errorMessage = session.getSessionStatus().getErrorText();
// Report the test case failure gracefully in case the system server is
// crashed during the test case.
Map.Entry<TestIdentifier, TestStatus> lastTestCase =
mTestStatuses.entrySet().stream().findFirst().orElse(null);
if (lastTestCase != null
&& !IInstrumentationResultParser.StatusCodes.isTerminalState(
lastTestCase.getValue().getTestResultCode())) {
for (ITestRunListener listener : mListeners) {
listener.testFailed(lastTestCase.getKey(), /*trace=*/ "");
}
lastTestCase
.getValue()
.putTestMetrics(
StatusKeys.DDMLIB_LOGCAT,
lastTestCase.getValue().getLogcat());
for (ITestRunListener listener : mListeners) {
listener.testEnded(
lastTestCase.getKey(),
lastTestCase.getValue().getTestMetrics());
}
}
for (ITestRunListener listener : mListeners) {
listener.testRunFailed(errorMessage);
}
}
break;
// All tests run finishes.
case SESSION_FINISHED:
if (session.getSessionStatus().getResultCode()
== IInstrumentationResultParser.SessionResultCodes.ERROR) {
String errorMessage =
session.getSessionStatus()
.getResults()
.getEntriesList()
.stream()
.filter(entry -> entry.getKey().equals(StatusKeys.SHORTMSG))
.map(entry -> entry.getValueString())
.findFirst()
.orElse("");
for (ITestRunListener listener : mListeners) {
listener.testRunFailed(errorMessage);
}
}
for (InstrumentationData.ResultsBundleEntry entry :
session.getSessionStatus().getResults().getEntriesList()) {
if (!StatusKeys.KNOWN_KEYS.contains(entry.getKey())) {
testRunMetrics.put(
entry.getKey(), getResultsEntryBundleValueInString(entry));
}
}
break;
default:
/* Unknown status */
break;
}
long elapsedTime = findElapsedTime(session.getSessionStatus()).orElse(0L);
ImmutableMap<String, String> immutableTestRunMetrics =
ImmutableMap.copyOf(testRunMetrics);
for (ITestRunListener listener : mListeners) {
listener.testRunEnded(elapsedTime, immutableTestRunMetrics);
}
}
}
private static String getResultsEntryBundleValueInString(
InstrumentationData.ResultsBundleEntry entry) {
if (entry.hasValueString()) return entry.getValueString();
if (entry.hasValueInt()) return String.valueOf(entry.getValueInt());
if (entry.hasValueLong()) return String.valueOf(entry.getValueLong());
if (entry.hasValueFloat()) return String.valueOf(entry.getValueFloat());
if (entry.hasValueDouble()) return String.valueOf(entry.getValueDouble());
if (entry.hasValueBytes()) return entry.getValueBytes().toString();
if (entry.hasValueBundle()) return entry.getValueBundle().toString();
return "";
}
private void updateTestState(
String testClassName,
String testMethodName,
int currentTestIndex,
int testResultCode,
String logcat,
String stackTrace,
LinkedHashMap<String, String> testMetrics) {
TestIdentifier testId = new TestIdentifier(testClassName, testMethodName, currentTestIndex);
TestStatus status =
mTestStatuses.computeIfAbsent(
testId,
id -> {
// Notify test case started.
for (ITestRunListener listener : mListeners) {
listener.testStarted(testId);
}
return new TestStatus(IInstrumentationResultParser.StatusCodes.START);
});
status.appendLogcat(logcat);
status.putAllTestMetrics(testMetrics);
if (status.getTestResultCode() == testResultCode) {
return;
}
status.setTestResultCode(testResultCode);
switch (testResultCode) {
case IInstrumentationResultParser.StatusCodes.ASSUMPTION_FAILURE:
for (ITestRunListener listener : mListeners) {
listener.testAssumptionFailure(testId, stackTrace);
}
break;
case IInstrumentationResultParser.StatusCodes.FAILURE:
case IInstrumentationResultParser.StatusCodes.ERROR:
for (ITestRunListener listener : mListeners) {
listener.testFailed(testId, stackTrace);
}
break;
case IInstrumentationResultParser.StatusCodes.IGNORED:
for (ITestRunListener listener : mListeners) {
listener.testIgnored(testId);
}
// If the test status code is IGNORED, don't report logcat message given by
// "am instrument" command because they are incorrect. (It seems it forgets
// to clear out logcat message from the previous tests and passes the same
// logcat message for the ignored tests).
status.clearLogcat();
break;
}
if (IInstrumentationResultParser.StatusCodes.isTerminalState(testResultCode)) {
// Add extra test metrics by ddmlib.
status.putTestMetrics(StatusKeys.DDMLIB_LOGCAT, status.getLogcat());
for (ITestRunListener listener : mListeners) {
listener.testEnded(testId, status.getTestMetrics());
}
}
}
/** Find an elapsed time string ("Time: 1,745.755") in a given session status. */
private Optional<Long> findElapsedTime(InstrumentationData.SessionStatus sessionStatus) {
String sessionOutput =
sessionStatus
.getResults()
.getEntriesList()
.stream()
.filter(entry -> entry.getKey().equals(StatusKeys.STREAM))
.map(entry -> entry.getValueString())
.findFirst()
.orElse(null);
if (isNullOrEmpty(sessionOutput)) {
return Optional.empty();
}
Matcher timeMatcher = mTimePattern.matcher(sessionOutput);
if (timeMatcher.find()) {
String timeString = timeMatcher.group(1);
try {
float timeSeconds = NumberFormat.getInstance().parse(timeString).floatValue();
return Optional.of((long) (timeSeconds * 1000));
} catch (ParseException e) {
Log.w(LOG_TAG, String.format("Unexpected time format %1$s", timeString));
}
}
Log.w(LOG_TAG, String.format("Elapsed time is missing: %1$s", sessionOutput));
return Optional.empty();
}
@Override
public void handleTestRunFailed(String errorMsg) {
if (mState.isTerminalState()) {
return;
}
mState = InstrumentationProtoResultParserState.FINISHED;
for (ITestRunListener listener : mListeners) {
listener.testRunFailed(errorMsg);
}
for (ITestRunListener listener : mListeners) {
listener.testRunEnded(0, ImmutableMap.of());
}
}
@Override
public void flush() {
/** Noop. We process data immediately. Nothing to flush. */
}
@Override
public void cancel() {
mState = InstrumentationProtoResultParserState.CANCELLED;
}
@Override
public boolean isCancelled() {
return mState == InstrumentationProtoResultParserState.CANCELLED;
}
}