/*
* Copyright (C) 2017 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 androidx.test.orchestrator;
import static androidx.test.orchestrator.OrchestratorConstants.AJUR_CLASS_ARGUMENT;
import static androidx.test.orchestrator.OrchestratorConstants.AJUR_LIST_TESTS_ARGUMENT;
import static androidx.test.orchestrator.OrchestratorConstants.ISOLATED_ARGUMENT;
import static androidx.test.orchestrator.OrchestratorConstants.ORCHESTRATOR_FORWARDED_INSTRUMENTATION_ARGS;
import static androidx.test.orchestrator.OrchestratorConstants.TARGET_INSTRUMENTATION_ARGUMENT;
import android.content.Context;
import android.os.Bundle;
import android.os.RemoteException;
import android.util.Log;
import androidx.annotation.VisibleForTesting;
import androidx.test.services.shellexecutor.ClientNotConnected;
import androidx.test.services.shellexecutor.ShellExecutorFactory;
import com.google.common.io.ByteStreams;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Runnable to run a single am instrument command to execute a single test. */
public class TestRunnable implements Runnable {
private static final String TAG = "TestRunnable";
private final Bundle arguments;
private final RunFinishedListener listener;
private final OutputStream outputStream;
private final String test;
private final boolean collectTests;
private final Context context;
private final String secret;
/**
* Constructs a TestRunnable executes all tests in arguments.
*
* @param context A context
* @param secret A string representing the speakeasy binder key
* @param arguments contains arguments to be passed to the target instrumentation
* @param outputStream the stream to write the results of the test process
* @param listener a callback listener to know when the run has completed
*/
public static TestRunnable legacyTestRunnable(
Context context,
String secret,
Bundle arguments,
OutputStream outputStream,
RunFinishedListener listener) {
return new TestRunnable(context, secret, arguments, outputStream, listener, null, false);
}
/**
* Constructs a TestRunnable which will run a single test.
*
* @param context A context
* @param secret A string representing the speakeasy binder key
* @param arguments contains arguments to be passed to the target instrumentation
* @param outputStream the stream to write the results of the test process
* @param listener a callback listener to know when the run has completed
* @param test contains a specific test#method to run. Will override whatever is specified in the
* bundle.
*/
public static TestRunnable singleTestRunnable(
Context context,
String secret,
Bundle arguments,
OutputStream outputStream,
RunFinishedListener listener,
String test) {
return new TestRunnable(context, secret, arguments, outputStream, listener, test, false);
}
/**
* Constructs a TestRunnable which will ask the instrumentation to list out its tests.
*
* @param context A context
* @param secret A string representing the speakeasy binder key
* @param arguments contains arguments to be passed to the target instrumentation
* @param outputStream the stream to write the results of the test process
* @param listener a callback listener to know when the run has completed
*/
public static TestRunnable testCollectionRunnable(
Context context,
String secret,
Bundle arguments,
OutputStream outputStream,
RunFinishedListener listener) {
return new TestRunnable(context, secret, arguments, outputStream, listener, null, true);
}
@VisibleForTesting
TestRunnable(
Context context,
String secret,
Bundle arguments,
OutputStream outputStream,
RunFinishedListener listener,
String test,
boolean collectTests) {
this.context = context;
this.secret = secret;
this.arguments = new Bundle(arguments);
this.outputStream = outputStream;
this.listener = listener;
this.test = test;
this.collectTests = collectTests;
}
/** Called at the end of a test run. */
public interface RunFinishedListener {
void runFinished();
}
@Override
public void run() {
try {
InputStream inputStream =
runShellCommand(buildShellParams(getTargetInstrumentationArguments()));
try {
ByteStreams.copy(inputStream, outputStream);
} finally {
if (inputStream != null) {
inputStream.close();
outputStream.close();
} else {
Log.e(TAG, "InputStream returned from shell command is null");
}
}
} catch (IOException e) {
Log.e(TAG, "IOException thrown when running remote test", e);
} catch (ClientNotConnected e) {
Log.e(TAG, "ShellCommandClient not connected, unable to run remote test", e);
} catch (InterruptedException e) {
Log.e(TAG, "ShellCommandClient connection interrupted, unable to run remote test", e);
} catch (RemoteException e) {
Log.e(TAG, "ShellCommandClient remote execution, unable to run remote test", e);
}
listener.runFinished();
}
private String getTargetInstrumentation() {
return arguments.getString(TARGET_INSTRUMENTATION_ARGUMENT);
}
private Bundle getTargetInstrumentationArguments() {
Bundle targetArgs = new Bundle(arguments);
// Filter out the only argument intended specifically for Listener
targetArgs.remove(TARGET_INSTRUMENTATION_ARGUMENT);
targetArgs.remove(ISOLATED_ARGUMENT);
if (collectTests) {
targetArgs.putString(AJUR_LIST_TESTS_ARGUMENT, "true");
} else {
// If we aren't engaging in test collection, then we should have a specific test target, and
// the orchestrator will pass a specific class parameter. Passing class and package parameters
// at the same time breaks AndroidJUnitRunner and is redundant. Thus, we can remove these
// parameters.
targetArgs.remove("package");
targetArgs.remove("testFile");
}
// Override the class parameter with the current test target.
if (test != null) {
targetArgs.putString(AJUR_CLASS_ARGUMENT, test);
}
return targetArgs;
}
/**
* Instrumentation params are delimited by comma, each param is stripped from leading and trailing
* whitespace.
*
* <p>The order of the params are critical to the correctness here as we split up params that have
* whitespace (eg: key value) into two different params `key` and `value` which means that those
* two different params must be next to each other the entire time.
*/
private List<String> getInstrumentationParamsAndRemoveBundleArgs(Bundle arguments) {
List<String> cleanedParams = new ArrayList<>();
String forwardedArgs = arguments.getString(ORCHESTRATOR_FORWARDED_INSTRUMENTATION_ARGS);
if (forwardedArgs != null) {
for (String param : forwardedArgs.split(",")) {
// ShellExecutor exhibits weird behavior when a param has a whitespace in it.
// so we need to split by white-space to remove the spaces.
Collections.addAll(cleanedParams, param.strip().split(" "));
}
arguments.remove(ORCHESTRATOR_FORWARDED_INSTRUMENTATION_ARGS);
}
return cleanedParams;
}
/**
* This method must maintain the order of the params
*
* <p>The order of the params are critical to the correctness here as we split up params that have
* whitespace (eg: key value) into two different params `key` and `value` which means that those
* two different params must be next to each other the entire time.
*/
private List<String> buildShellParams(Bundle arguments) throws IOException, ClientNotConnected {
List<String> params = new ArrayList<>();
params.add("instrument");
params.add("-w");
params.add("-r");
params.addAll(getInstrumentationParamsAndRemoveBundleArgs(arguments));
for (String key : arguments.keySet()) {
params.add("-e");
params.add(key);
params.add(arguments.getString(key));
}
params.add(getTargetInstrumentation());
for (String param : params) {
if (param.isEmpty() || param.contains(" ")) {
throw new IllegalStateException(
"Params must not contain any white-space to avoid encoding issues.");
}
}
return params;
}
InputStream runShellCommand(List<String> params)
throws IOException, ClientNotConnected, InterruptedException, RemoteException {
return new ShellExecutorFactory(context, secret)
.create()
.executeShellCommand("am", params, null, false);
}
}