Introduce new android_instrumentation_test rule.

This rule works with the android_instrumentation, android_device_script_fixture and android_host_service_fixture rules to support testing Android applications.

None of these rules are installed yet, because the forthcoming Android test runner is not yet open sourced.

https://github.com/bazelbuild/bazel/issues/903.

RELNOTES: None
PiperOrigin-RevId: 160465920
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index 9be7d83..3091e7f 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -1058,7 +1058,9 @@
     ),
     resources = [
         "rules/android/android_device_stub_template.txt",
+        "rules/android/android_instrumentation_test_template.txt",
         "rules/android/databinding_annotation_template.txt",
+        "rules/android/test_suite_property_name.txt",
     ],
     deps = [
         ":build-base",
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidDeviceScriptFixture.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidDeviceScriptFixture.java
index 648b282..a422e62 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidDeviceScriptFixture.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidDeviceScriptFixture.java
@@ -20,6 +20,7 @@
 import com.google.devtools.build.lib.analysis.RuleConfiguredTarget.Mode;
 import com.google.devtools.build.lib.analysis.RuleConfiguredTargetBuilder;
 import com.google.devtools.build.lib.analysis.RuleContext;
+import com.google.devtools.build.lib.analysis.Runfiles;
 import com.google.devtools.build.lib.analysis.RunfilesProvider;
 import com.google.devtools.build.lib.analysis.TransitiveInfoCollection;
 import com.google.devtools.build.lib.analysis.actions.FileWriteAction;
@@ -36,7 +37,12 @@
     Artifact fixtureScript = getFixtureScript(ruleContext);
     return new RuleConfiguredTargetBuilder(ruleContext)
         .setFilesToBuild(NestedSetBuilder.<Artifact>stableOrder().add(fixtureScript).build())
-        .addProvider(RunfilesProvider.class, RunfilesProvider.EMPTY)
+        .addProvider(
+            RunfilesProvider.class,
+            RunfilesProvider.simple(
+                new Runfiles.Builder(ruleContext.getWorkspaceName())
+                    .addArtifact(fixtureScript)
+                    .build()))
         .addNativeDeclaredProvider(
             new AndroidDeviceScriptFixtureInfoProvider(
                 fixtureScript,
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixture.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixture.java
index 2ae4c81..a5f8935 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixture.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixture.java
@@ -51,6 +51,7 @@
         .addProvider(RunfilesProvider.class, RunfilesProvider.simple(runfiles))
         .addNativeDeclaredProvider(
             new AndroidHostServiceFixtureInfoProvider(
+                executable.getExecutable(),
                 ruleContext.getTokenizedStringListAttr("service_names"),
                 AndroidCommon.getSupportApks(ruleContext),
                 ruleContext.attributes().get("provides_test_args", Type.BOOLEAN),
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureInfoProvider.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureInfoProvider.java
index 715bfbc..3acd378 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureInfoProvider.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureInfoProvider.java
@@ -34,21 +34,28 @@
   static final NativeClassObjectConstructor ANDROID_HOST_SERVICE_FIXTURE_INFO =
       new NativeClassObjectConstructor(SKYLARK_NAME) {};
 
+  private final Artifact executable;
   private final ImmutableList<String> serviceNames;
   private final NestedSet<Artifact> supportApks;
   private final boolean providesTestArgs;
-  private final boolean isDaemon;
+  private final boolean daemon;
 
   AndroidHostServiceFixtureInfoProvider(
+      Artifact executable,
       ImmutableList<String> serviceNames,
       NestedSet<Artifact> supportApks,
       boolean providesTestArgs,
       boolean isDaemon) {
     super(ANDROID_HOST_SERVICE_FIXTURE_INFO, ImmutableMap.<String, Object>of());
+    this.executable = executable;
     this.serviceNames = serviceNames;
     this.supportApks = supportApks;
     this.providesTestArgs = providesTestArgs;
-    this.isDaemon = isDaemon;
+    this.daemon = isDaemon;
+  }
+
+  public Artifact getExecutable() {
+    return executable;
   }
 
   public ImmutableList<String> getServiceNames() {
@@ -63,7 +70,7 @@
     return providesTestArgs;
   }
 
-  public boolean getIsDaemon() {
-    return isDaemon;
+  public boolean getDaemon() {
+    return daemon;
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentation.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentation.java
index 75587bc..d281b10 100644
--- a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentation.java
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentation.java
@@ -20,9 +20,11 @@
 import com.google.devtools.build.lib.analysis.RuleConfiguredTarget.Mode;
 import com.google.devtools.build.lib.analysis.RuleConfiguredTargetBuilder;
 import com.google.devtools.build.lib.analysis.RuleContext;
+import com.google.devtools.build.lib.analysis.Runfiles;
 import com.google.devtools.build.lib.analysis.RunfilesProvider;
 import com.google.devtools.build.lib.analysis.TransitiveInfoCollection;
 import com.google.devtools.build.lib.analysis.actions.SymlinkAction;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
 import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
 import com.google.devtools.build.lib.packages.ImplicitOutputsFunction;
 import com.google.devtools.build.lib.packages.ImplicitOutputsFunction.SafeImplicitOutputsFunction;
@@ -47,12 +49,18 @@
 
     Artifact targetApk = getTargetApk(ruleContext);
     Artifact instrumentationApk = createInstrumentationApk(ruleContext);
+    NestedSet<Artifact> filesToBuild =
+        NestedSetBuilder.<Artifact>stableOrder().add(targetApk).add(instrumentationApk).build();
 
     RuleConfiguredTargetBuilder ruleBuilder = new RuleConfiguredTargetBuilder(ruleContext);
     return ruleBuilder
-        .setFilesToBuild(
-            NestedSetBuilder.<Artifact>stableOrder().add(targetApk).add(instrumentationApk).build())
-        .addProvider(RunfilesProvider.class, RunfilesProvider.EMPTY)
+        .setFilesToBuild(filesToBuild)
+        .addProvider(
+            RunfilesProvider.class,
+            RunfilesProvider.simple(
+                new Runfiles.Builder(ruleContext.getWorkspaceName())
+                    .addTransitiveArtifacts(filesToBuild)
+                    .build()))
         .addNativeDeclaredProvider(
             new AndroidInstrumentationInfoProvider(targetApk, instrumentationApk))
         .build();
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTest.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTest.java
new file mode 100644
index 0000000..0e5c717
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTest.java
@@ -0,0 +1,334 @@
+// Copyright 2017 The Bazel Authors. All rights reserved.
+//
+// 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.google.devtools.build.lib.rules.android;
+
+import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.analysis.ConfiguredTarget;
+import com.google.devtools.build.lib.analysis.FilesToRunProvider;
+import com.google.devtools.build.lib.analysis.RuleConfiguredTarget.Mode;
+import com.google.devtools.build.lib.analysis.RuleConfiguredTargetBuilder;
+import com.google.devtools.build.lib.analysis.RuleContext;
+import com.google.devtools.build.lib.analysis.Runfiles;
+import com.google.devtools.build.lib.analysis.RunfilesProvider;
+import com.google.devtools.build.lib.analysis.RunfilesSupport;
+import com.google.devtools.build.lib.analysis.TransitiveInfoCollection;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction.Substitution;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction.Template;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
+import com.google.devtools.build.lib.rules.RuleConfiguredTargetFactory;
+import com.google.devtools.build.lib.rules.test.ExecutionInfoProvider;
+import com.google.devtools.build.lib.syntax.Type;
+import com.google.devtools.build.lib.util.ResourceFileLoader;
+import java.io.IOException;
+import java.util.stream.StreamSupport;
+import javax.annotation.Nullable;
+
+/** An implementation of the {@code android_instrumentation} rule. */
+public class AndroidInstrumentationTest implements RuleConfiguredTargetFactory {
+
+  private static final Template ANDROID_INSTRUMENTATION_TEST_STUB_SCRIPT =
+      Template.forResource(
+          AndroidInstrumentationTest.class, "android_instrumentation_test_template.txt");
+  private static final String TEST_SUITE_PROPERTY_NAME_FILE = "test_suite_property_name.txt";
+
+  @Override
+  public ConfiguredTarget create(RuleContext ruleContext)
+      throws InterruptedException, RuleErrorException {
+    // The wrapper script that invokes the test entry point.
+    Artifact testExecutable = createTestExecutable(ruleContext);
+
+    ImmutableList<TransitiveInfoCollection> runfilesDeps =
+        ImmutableList.<TransitiveInfoCollection>builder()
+            .addAll(ruleContext.getPrerequisites("instrumentations", Mode.TARGET))
+            .addAll(ruleContext.getPrerequisites("fixtures", Mode.TARGET))
+            .add(ruleContext.getPrerequisite("target_device", Mode.HOST))
+            .add(ruleContext.getPrerequisite("$test_entry_point", Mode.HOST))
+            .build();
+
+    Runfiles runfiles =
+        new Runfiles.Builder(ruleContext.getWorkspaceName())
+            .addArtifact(testExecutable)
+            .addTargets(runfilesDeps, RunfilesProvider.DEFAULT_RUNFILES)
+            .addTransitiveArtifacts(AndroidCommon.getSupportApks(ruleContext))
+            .addTransitiveArtifacts(getAdb(ruleContext).getFilesToRun())
+            .addArtifacts(getDataDeps(ruleContext))
+            .build();
+
+    return new RuleConfiguredTargetBuilder(ruleContext)
+        .setFilesToBuild(NestedSetBuilder.<Artifact>stableOrder().add(testExecutable).build())
+        .addProvider(RunfilesProvider.class, RunfilesProvider.simple(runfiles))
+        .setRunfilesSupport(
+            RunfilesSupport.withExecutable(ruleContext, runfiles, testExecutable), testExecutable)
+        .addNativeDeclaredProvider(getExecutionInfoProvider(ruleContext))
+        .build();
+  }
+
+  /** Registers a {@link TemplateExpansionAction} to write the test executable. */
+  private Artifact createTestExecutable(RuleContext ruleContext) throws RuleErrorException {
+    Artifact testExecutable = ruleContext.createOutputArtifact();
+    ruleContext.registerAction(
+        new TemplateExpansionAction(
+            ruleContext.getActionOwner(),
+            testExecutable,
+            ANDROID_INSTRUMENTATION_TEST_STUB_SCRIPT,
+            getTemplateSubstitutions(ruleContext),
+            /* makeExecutable = */ true));
+    return testExecutable;
+  }
+
+  /**
+   * This method defines all substitutions need to fill in {@link
+   * #ANDROID_INSTRUMENTATION_TEST_STUB_SCRIPT}.
+   */
+  private ImmutableList<Substitution> getTemplateSubstitutions(RuleContext ruleContext)
+      throws RuleErrorException {
+    return ImmutableList.<Substitution>builder()
+        .add(Substitution.of("%workspace%", ruleContext.getWorkspaceName()))
+        .add(Substitution.of("%test_label%", ruleContext.getLabel().getCanonicalForm()))
+        .add(executableSubstitution("%adb%", getAdb(ruleContext)))
+        .add(executableSubstitution("%device_script%", getTargetDevice(ruleContext)))
+        .add(executableSubstitution("%test_entry_point%", getTestEntryPoint(ruleContext)))
+        .add(artifactListSubstitution("%target_apks%", getTargetApks(ruleContext)))
+        .add(
+            artifactListSubstitution("%instrumentation_apks%", getInstrumentationApks(ruleContext)))
+        .add(artifactListSubstitution("%support_apks%", getAllSupportApks(ruleContext)))
+        .add(Substitution.ofSpaceSeparatedMap("%test_args%", getTestArgs(ruleContext)))
+        .add(Substitution.ofSpaceSeparatedMap("%fixture_args%", getFixtureArgs(ruleContext)))
+        .add(Substitution.ofSpaceSeparatedMap("%log_levels%", getLogLevels(ruleContext)))
+        .add(deviceScriptFixturesSubstitution(ruleContext))
+        .addAll(hostServiceFixturesSubstitutions(ruleContext))
+        .add(artifactListSubstitution("%data_deps%", getDataDeps(ruleContext)))
+        .add(Substitution.of("%device_broker_type%", getDeviceBrokerType(ruleContext)))
+        .add(Substitution.of("%test_suite_property_name%", getTestSuitePropertyName(ruleContext)))
+        .build();
+  }
+
+  /**
+   * An ad-hoc substitution to put the information from the {@code android_device_script_fixture}s
+   * into the bash stub script.
+   *
+   * <p>TODO(ajmichael): Determine an actual protocol to pass this information to the test suite.
+   */
+  private static Substitution deviceScriptFixturesSubstitution(RuleContext ruleContext) {
+    ImmutableList.Builder<String> builder = ImmutableList.builder();
+    for (AndroidDeviceScriptFixtureInfoProvider deviceScriptFixture :
+        getDeviceScriptFixtures(ruleContext)) {
+      builder.add(
+          String.format(
+              "[%s]=%b,%b",
+              deviceScriptFixture.getFixtureScript().getRunfilesPathString(),
+              deviceScriptFixture.getDaemon(),
+              deviceScriptFixture.getStrictExit()));
+    }
+    return Substitution.ofSpaceSeparatedList("%device_script_fixtures%", builder.build());
+  }
+
+  /**
+   * An ad-hoc substitution to put the information from the {@code android_host_service_fixture}s
+   * into the bash stub script.
+   *
+   * <p>TODO(ajmichael): Determine an actual protocol to pass this information to the test suite.
+   */
+  private static ImmutableList<Substitution> hostServiceFixturesSubstitutions(
+      RuleContext ruleContext) {
+    AndroidHostServiceFixtureInfoProvider hostServiceFixture = getHostServiceFixture(ruleContext);
+    return ImmutableList.of(
+        Substitution.of(
+            "%host_service_fixture%",
+            hostServiceFixture != null
+                ? hostServiceFixture.getExecutable().getRunfilesPathString()
+                : ""),
+        Substitution.of(
+            "%host_service_fixture_services%",
+            hostServiceFixture != null
+                ? Joiner.on(",").join(hostServiceFixture.getServiceNames())
+                : ""));
+  }
+
+  private static Substitution executableSubstitution(
+      String key, FilesToRunProvider filesToRunProvider) {
+    return Substitution.of(key, filesToRunProvider.getExecutable().getRunfilesPathString());
+  }
+
+  private static Substitution artifactListSubstitution(String key, Iterable<Artifact> artifacts) {
+    return Substitution.ofSpaceSeparatedList(
+        key,
+        StreamSupport.stream(artifacts.spliterator(), false)
+            .map(Artifact::getRunfilesPathString)
+            .collect(ImmutableList.toImmutableList()));
+  }
+
+  /**
+   * The target APKs from each {@code android_instrumentation} in the {@code instrumentations}
+   * attribute.
+   */
+  private static Iterable<Artifact> getTargetApks(RuleContext ruleContext) {
+    return Iterables.transform(
+        ruleContext.getPrerequisites(
+            "instrumentations",
+            Mode.TARGET,
+            AndroidInstrumentationInfoProvider.ANDROID_INSTRUMENTATION_INFO.getKey(),
+            AndroidInstrumentationInfoProvider.class),
+        AndroidInstrumentationInfoProvider::getTargetApk);
+  }
+
+  /**
+   * The instrumentation APKs from each {@code android_instrumentation} in the {@code
+   * instrumentations} attribute.
+   */
+  private static Iterable<Artifact> getInstrumentationApks(RuleContext ruleContext) {
+    return Iterables.transform(
+        ruleContext.getPrerequisites(
+            "instrumentations",
+            Mode.TARGET,
+            AndroidInstrumentationInfoProvider.ANDROID_INSTRUMENTATION_INFO.getKey(),
+            AndroidInstrumentationInfoProvider.class),
+        AndroidInstrumentationInfoProvider::getInstrumentationApk);
+  }
+
+  /** The support APKs from the {@code support_apks} and {@code fixtures} attributes. */
+  private static NestedSet<Artifact> getAllSupportApks(RuleContext ruleContext) {
+    NestedSetBuilder<Artifact> allSupportApks =
+        NestedSetBuilder.<Artifact>stableOrder()
+            .addTransitive(AndroidCommon.getSupportApks(ruleContext));
+    for (AndroidDeviceScriptFixtureInfoProvider fixture :
+        ruleContext.getPrerequisites(
+            "fixtures", Mode.TARGET, AndroidDeviceScriptFixtureInfoProvider.class)) {
+      allSupportApks.addTransitive(fixture.getSupportApks());
+    }
+    for (AndroidHostServiceFixtureInfoProvider fixture :
+        ruleContext.getPrerequisites(
+            "fixtures",
+            Mode.TARGET,
+            AndroidInstrumentationInfoProvider.ANDROID_INSTRUMENTATION_INFO.getKey(),
+            AndroidHostServiceFixtureInfoProvider.class)) {
+      allSupportApks.addTransitive(fixture.getSupportApks());
+    }
+    return allSupportApks.build();
+  }
+
+  /** The deploy jar that interacts with the device. */
+  private static FilesToRunProvider getTestEntryPoint(RuleContext ruleContext) {
+    return ruleContext.getExecutablePrerequisite("$test_entry_point", Mode.HOST);
+  }
+
+  /** The {@code android_device} script to launch an emulator for the test. */
+  private static FilesToRunProvider getTargetDevice(RuleContext ruleContext) {
+    return ruleContext.getExecutablePrerequisite("target_device", Mode.HOST);
+  }
+
+  /** ADB binary from the Android SDK. */
+  private static FilesToRunProvider getAdb(RuleContext ruleContext) {
+    return AndroidSdkProvider.fromRuleContext(ruleContext).getAdb();
+  }
+
+  /** Map of {@code test_args} for the test runner to make available to test test code. */
+  private static ImmutableMap<String, String> getTestArgs(RuleContext ruleContext) {
+    return ImmutableMap.copyOf(ruleContext.attributes().get("test_args", Type.STRING_DICT));
+  }
+
+  /** Map of {@code fixture_args} for the test runner to pass to the {@code fixtures}. */
+  private static ImmutableMap<String, String> getFixtureArgs(RuleContext ruleContext) {
+    return ImmutableMap.copyOf(ruleContext.attributes().get("fixture_args", Type.STRING_DICT));
+  }
+
+  /** Map of {@code log_levels} to enable before the test run. */
+  private static ImmutableMap<String, String> getLogLevels(RuleContext ruleContext) {
+    return ImmutableMap.copyOf(ruleContext.attributes().get("log_levels", Type.STRING_DICT));
+  }
+
+  private static ImmutableList<Artifact> getDataDeps(RuleContext ruleContext) {
+    return ruleContext.getPrerequisiteArtifacts("data", Mode.DATA).list();
+  }
+
+  /**
+   * Checks for a {@code android_host_service_fixture} in the {@code fixtures} attribute. Returns
+   * null if there is none, a {@link AndroidHostServiceFixtureInfoProvider} if there is one or
+   * throws an error if there is more than one.
+   */
+  @Nullable
+  private static AndroidHostServiceFixtureInfoProvider getHostServiceFixture(
+      RuleContext ruleContext) {
+    ImmutableList<AndroidHostServiceFixtureInfoProvider> hostServiceFixtures =
+        ImmutableList.copyOf(
+            ruleContext.getPrerequisites(
+                "fixtures",
+                Mode.TARGET,
+                AndroidHostServiceFixtureInfoProvider.ANDROID_HOST_SERVICE_FIXTURE_INFO.getKey(),
+                AndroidHostServiceFixtureInfoProvider.class));
+    if (hostServiceFixtures.size() > 1) {
+      ruleContext.ruleError(
+          "android_instrumentation_test accepts at most one android_host_service_fixture");
+    }
+    return Iterables.getFirst(hostServiceFixtures, null);
+  }
+
+  private static Iterable<AndroidDeviceScriptFixtureInfoProvider> getDeviceScriptFixtures(
+      RuleContext ruleContext) {
+    return ruleContext.getPrerequisites(
+        "fixtures",
+        Mode.TARGET,
+        AndroidDeviceScriptFixtureInfoProvider.ANDROID_DEVICE_SCRIPT_FIXTURE_INFO.getKey(),
+        AndroidDeviceScriptFixtureInfoProvider.class);
+  }
+
+  private static String getDeviceBrokerType(RuleContext ruleContext) {
+    return ruleContext
+        .getPrerequisite("target_device", Mode.HOST, DeviceBrokerTypeProvider.class)
+        .getDeviceBrokerType();
+  }
+
+  /**
+   * Returns the name of the test suite property that the test runner uses to determine which test
+   * suite to run.
+   *
+   * <p>This is stored in a separate resource file to facilitate different runners for internal and
+   * external Bazel.
+   */
+  private static String getTestSuitePropertyName(RuleContext ruleContext)
+      throws RuleErrorException {
+    try {
+      return ResourceFileLoader.loadResource(
+          AndroidInstrumentationTest.class, TEST_SUITE_PROPERTY_NAME_FILE);
+    } catch (IOException e) {
+      ruleContext.throwWithRuleError("Cannot load test suite property name: " + e.getMessage());
+      return null;
+    }
+  }
+
+  /**
+   * Propagates the {@link ExecutionInfoProvider} from the {@code android_device} rule in the {@code
+   * target_device} attribute.
+   *
+   * <p>This allows the dependent {@code android_device} rule to specify some requirements on the
+   * machine that the {@code android_instrumentation_test} runs on.
+   */
+  private static ExecutionInfoProvider getExecutionInfoProvider(RuleContext ruleContext) {
+    ExecutionInfoProvider executionInfoProvider =
+        (ExecutionInfoProvider)
+            ruleContext.getPrerequisite(
+                "target_device", Mode.HOST, ExecutionInfoProvider.SKYLARK_CONSTRUCTOR.getKey());
+    ImmutableMap<String, String> executionRequirements =
+        (executionInfoProvider != null)
+            ? executionInfoProvider.getExecutionInfo()
+            : ImmutableMap.of();
+    return new ExecutionInfoProvider(executionRequirements);
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestRule.java b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestRule.java
new file mode 100644
index 0000000..bb65e2e
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestRule.java
@@ -0,0 +1,83 @@
+// Copyright 2017 The Bazel Authors. All rights reserved.
+//
+// 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.google.devtools.build.lib.rules.android;
+
+import static com.google.devtools.build.lib.packages.Attribute.ConfigurationTransition.HOST;
+import static com.google.devtools.build.lib.packages.Attribute.attr;
+import static com.google.devtools.build.lib.packages.BuildType.LABEL;
+import static com.google.devtools.build.lib.packages.BuildType.LABEL_LIST;
+import static com.google.devtools.build.lib.syntax.Type.STRING_DICT;
+
+import com.google.devtools.build.lib.analysis.BaseRuleClasses;
+import com.google.devtools.build.lib.analysis.RuleDefinition;
+import com.google.devtools.build.lib.analysis.RuleDefinitionEnvironment;
+import com.google.devtools.build.lib.packages.RuleClass;
+import com.google.devtools.build.lib.packages.RuleClass.Builder;
+import com.google.devtools.build.lib.packages.RuleClass.Builder.RuleClassType;
+import com.google.devtools.build.lib.util.FileTypeSet;
+
+/** Rule definition for the {@code android_instrumentation_test} rule. */
+public class AndroidInstrumentationTestRule implements RuleDefinition {
+
+  @Override
+  public RuleClass build(Builder builder, RuleDefinitionEnvironment environment) {
+    return builder
+        .setUndocumented()
+        .add(
+            attr("instrumentations", LABEL_LIST)
+                .mandatory()
+                .allowedFileTypes(FileTypeSet.NO_FILE)
+                .allowedRuleClasses("android_instrumentation"))
+        .add(
+            attr("target_device", LABEL)
+                .mandatory()
+                .exec()
+                .cfg(HOST)
+                .allowedFileTypes(FileTypeSet.NO_FILE)
+                .allowedRuleClasses("android_device"))
+        .add(
+            attr("support_apks", LABEL_LIST)
+                .allowedFileTypes(AndroidRuleClasses.APK)
+                .allowedRuleClasses("android_binary"))
+        .add(attr("test_args", STRING_DICT))
+        .add(
+            attr("fixtures", LABEL_LIST)
+                .allowedFileTypes(FileTypeSet.NO_FILE)
+                .allowedRuleClasses(
+                    "android_device_script_fixture", "android_host_service_fixture"))
+        .add(attr("fixture_args", STRING_DICT))
+        .add(attr("log_levels", STRING_DICT))
+        .add(
+            attr("$test_entry_point", LABEL)
+                .exec()
+                .cfg(HOST)
+                .value(
+                    environment.getToolsLabel("//tools/android:instrumentation_test_entry_point")))
+        .removeAttribute("deps")
+        .removeAttribute("javacopts")
+        .removeAttribute("plugins")
+        .removeAttribute(":java_plugins")
+        .build();
+  }
+
+  @Override
+  public Metadata getMetadata() {
+    return RuleDefinition.Metadata.builder()
+        .name("android_instrumentation_test")
+        .type(RuleClassType.TEST)
+        .ancestors(AndroidRuleClasses.AndroidBaseRule.class, BaseRuleClasses.TestBaseRule.class)
+        .factoryClass(AndroidInstrumentationTest.class)
+        .build();
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/android_instrumentation_test_template.txt b/src/main/java/com/google/devtools/build/lib/rules/android/android_instrumentation_test_template.txt
new file mode 100644
index 0000000..c4af683
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/android_instrumentation_test_template.txt
@@ -0,0 +1,88 @@
+#!/bin/bash --posix
+# Copyright 2017 The Bazel Authors. All rights reserved.
+#
+# 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.
+
+set -eux
+
+# Unset TESTBRIDGE_TEST_ONLY environment variable set by Bazel's --test_filter
+# flag so that JUnit3 doesn't filter out the Android test suite class. Instead,
+# forward this variable as a Java flag with the same name.
+if [ -z "${TESTBRIDGE_TEST_ONLY+1}" ]; then
+  ANDROID_TESTBRIDGE_TEST_ONLY=""
+else
+  ANDROID_TESTBRIDGE_TEST_ONLY=${TESTBRIDGE_TEST_ONLY}
+  unset TESTBRIDGE_TEST_ONLY
+fi
+
+function join_paths() {
+  local base_dir=$1
+  local sep=$2
+  shift 2
+  local paths=$@
+
+  local result=""
+  for path in $paths
+  do
+    result=${base_dir}/${path}${sep}${result}
+  done
+  echo ${result}
+}
+
+test_label="%test_label%"
+test_entry_point="%test_entry_point%"
+log_levels="%log_levels%"
+WORKSPACE_DIR="${TEST_SRCDIR}/%workspace%"
+adb="${WORKSPACE_DIR}/%adb%"
+device_script="${WORKSPACE_DIR}/%device_script%"
+
+data_deps="%data_deps%"
+data_deps=$(join_paths ${WORKSPACE_DIR} "," ${data_deps})
+
+device_broker_type="%device_broker_type%"
+test_label="%test_label%"
+
+target_apks="%target_apks%"
+target_apks=$(join_paths ${WORKSPACE_DIR} "," ${target_apks})
+
+instrumentation_apks="%instrumentation_apks%"
+instrumentation_apks=$(join_paths ${WORKSPACE_DIR} "," ${instrumentation_apks})
+
+support_apks="%support_apks%"
+support_apks=$(join_paths ${WORKSPACE_DIR} "," ${support_apks})
+
+apks_to_install="${support_apks}${target_apks}${instrumentation_apks}"
+
+declare -A device_script_fixtures=( %device_script_fixtures% )
+
+host_service_fixture="%host_service_fixture%"
+host_service_fixture_services="%host_service_fixture_services%"
+
+fixture_args="%fixture_args%"
+
+test_suite_property_name='%test_suite_property_name%'
+
+$test_entry_point \
+    --wrapper_script_flag=--jvm_flag=-D$test_suite_property_name=com.google.android.apps.common.testing.suite.AndroidDeviceTestSuite \
+    --adb="${adb}" \
+    --device_broker_type="${device_broker_type}" \
+    --device_script="${device_script}" \
+    --data_deps="${data_deps}" \
+    --test_label="${test_label}" \
+    --apks_to_install="${apks_to_install}" \
+    --fixture_scripts="$(printf "%s," "${!device_script_fixtures[@]}")" \
+    --hermetic_server_script="${host_service_fixture}" \
+    --hermetic_servers="${host_service_fixture_services}" \
+    --data_deps="$(printf "%s," "${!device_script_fixtures[@]}")" \
+    --test_filter="${ANDROID_TESTBRIDGE_TEST_ONLY}" \
+    "$@"
diff --git a/src/main/java/com/google/devtools/build/lib/rules/android/test_suite_property_name.txt b/src/main/java/com/google/devtools/build/lib/rules/android/test_suite_property_name.txt
new file mode 100644
index 0000000..650406a
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/rules/android/test_suite_property_name.txt
@@ -0,0 +1 @@
+bazel.test_suite
diff --git a/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java b/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
index 34cfc71..151466a 100644
--- a/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
+++ b/src/test/java/com/google/devtools/build/lib/analysis/mock/BazelAnalysisMock.java
@@ -234,6 +234,7 @@
         .add("            jars = [ 'idlclass.jar' ])")
         .add("exports_files(['adb', 'adb_static'])")
         .add("sh_binary(name = 'android_runtest', srcs = ['empty.sh'])")
+        .add("sh_binary(name = 'instrumentation_test_entry_point', srcs = ['empty.sh'])")
         .add("java_plugin(name = 'databinding_annotation_processor',")
         .add("    processor_class = 'android.databinding.annotationprocessor.ProcessDataBinding')");
 
diff --git a/src/test/java/com/google/devtools/build/lib/rules/android/AndroidBuildViewTestCase.java b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidBuildViewTestCase.java
index ce74662..4e9d40a 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/android/AndroidBuildViewTestCase.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidBuildViewTestCase.java
@@ -56,6 +56,7 @@
         .addRuleDefinition(new AndroidDeviceScriptFixtureRule())
         .addRuleDefinition(new AndroidHostServiceFixtureRule())
         .addRuleDefinition(new AndroidInstrumentationRule())
+        .addRuleDefinition(new AndroidInstrumentationTestRule())
         .build();
   }
 
diff --git a/src/test/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureTest.java b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureTest.java
index 1d77e5d..e0954c0 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureTest.java
+++ b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidHostServiceFixtureTest.java
@@ -152,11 +152,11 @@
         ")");
     assertThat(
             getHostServiceFixtureInfoProvider(getConfiguredTarget("//javatests/com/app:no_daemon"))
-                .getIsDaemon())
+                .getDaemon())
         .isFalse();
     assertThat(
             getHostServiceFixtureInfoProvider(getConfiguredTarget("//javatests/com/app:daemon"))
-                .getIsDaemon())
+                .getDaemon())
         .isTrue();
   }
 
diff --git a/src/test/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestTest.java b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestTest.java
new file mode 100644
index 0000000..66f08f8
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/rules/android/AndroidInstrumentationTestTest.java
@@ -0,0 +1,254 @@
+// Copyright 2017 The Bazel Authors. All rights reserved.
+//
+// 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.google.devtools.build.lib.rules.android;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.actions.util.ActionsTestUtil.getFirstArtifactEndingWith;
+
+import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.analysis.ConfiguredTarget;
+import com.google.devtools.build.lib.analysis.FileProvider;
+import com.google.devtools.build.lib.analysis.FilesToRunProvider;
+import com.google.devtools.build.lib.analysis.RunfilesProvider;
+import com.google.devtools.build.lib.analysis.actions.TemplateExpansionAction;
+import com.google.devtools.build.lib.collect.nestedset.NestedSet;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link AndroidInstrumentationTest}. */
+@RunWith(JUnit4.class)
+public class AndroidInstrumentationTestTest extends AndroidBuildViewTestCase {
+
+  @Before
+  public void setup() throws Exception {
+    scratch.file(
+        "java/com/app/BUILD",
+        "android_binary(",
+        "  name = 'app1',",
+        "  manifest = 'AndroidManifest.xml',",
+        ")",
+        "android_binary(",
+        "  name = 'app2',",
+        "  manifest = 'AndroidManifest.xml',",
+        ")",
+        "android_binary(",
+        "  name = 'support',",
+        "  manifest = 'AndroidManifest.xml',",
+        ")");
+    scratch.file(
+        "javatests/com/app/BUILD",
+        "android_binary(",
+        "  name = 'instrumentation_app1',",
+        "  manifest = 'AndroidManifest.xml',",
+        ")",
+        "android_instrumentation(",
+        "  name = 'instrumentation1',",
+        "  target = '//java/com/app:app1',",
+        "  instrumentation = ':instrumentation_app1',",
+        ")",
+        "android_binary(",
+        "  name = 'instrumentation_app2',",
+        "  manifest = 'AndroidManifest.xml',",
+        ")",
+        "android_instrumentation(",
+        "  name = 'instrumentation2',",
+        "  target = '//java/com/app:app2',",
+        "  instrumentation = ':instrumentation_app2',",
+        ")",
+        "android_device_script_fixture(",
+        "  name = 'device_fixture',",
+        "  cmd = 'foo bar',",
+        ")",
+        "android_host_service_fixture(",
+        "  name = 'host_fixture',",
+        "  executable = '//java/com/server',",
+        "  service_names = ['foo', 'bar'],",
+        ")");
+    scratch.file(
+        "java/com/server/BUILD",
+        "java_binary(",
+        "  name = 'server',",
+        "  main_class = 'does.not.exist',",
+        "  srcs = [],",
+        ")");
+    scratch.file(
+        "javatests/com/app/ait/BUILD",
+        "android_instrumentation_test(",
+        "  name = 'ait',",
+        "  instrumentations = [",
+        "    '//javatests/com/app:instrumentation1',",
+        "    '//javatests/com/app:instrumentation2',",
+        "  ],",
+        "  target_device = '//tools/android/emulated_device:nexus_6',",
+        "  fixtures = [",
+        "    '//javatests/com/app:device_fixture',",
+        "    '//javatests/com/app:host_fixture',",
+        "  ],",
+        "  support_apks = [",
+        "    '//java/com/app:support',",
+        "  ],",
+        "  data = [",
+        "    'foo.txt',",
+        "  ],",
+        ")");
+    setupTargetDevice();
+  }
+
+  // TODO(ajmichael): Share this with AndroidDeviceTest.java
+  private void setupTargetDevice() throws Exception {
+    scratch.file(
+        "tools/android/emulated_device/BUILD",
+        "filegroup(",
+        "  name = 'emulator_images_android_21_x86',",
+        "  srcs = [",
+        "    'android_21/x86/kernel-qemu',",
+        "    'android_21/x86/ramdisk.img',",
+        "    'android_21/x86/source.properties',",
+        "    'android_21/x86/system.img.tar.gz',",
+        "    'android_21/x86/userdata.img.tar.gz'",
+        "  ],",
+        ")",
+        "android_device(",
+        "  name = 'nexus_6',",
+        "  ram = 2047,",
+        "  horizontal_resolution = 720, ",
+        "  vertical_resolution = 1280, ",
+        "  cache = 32, ",
+        "  system_image = ':emulator_images_android_21_x86',",
+        "  screen_density = 280, ",
+        "  vm_heap = 256",
+        ")");
+  }
+
+  @Test
+  public void testTestExecutableRunfiles() throws Exception {
+    ConfiguredTarget androidInstrumentationTest = getConfiguredTarget("//javatests/com/app/ait");
+    NestedSet<Artifact> runfiles =
+        androidInstrumentationTest
+            .getProvider(RunfilesProvider.class)
+            .getDefaultRunfiles()
+            .getAllArtifacts();
+    assertThat(runfiles)
+        .containsAllIn(
+            getHostConfiguredTarget("//tools/android/emulated_device:nexus_6")
+                .getProvider(RunfilesProvider.class)
+                .getDefaultRunfiles()
+                .getAllArtifacts());
+    assertThat(runfiles)
+        .containsAllIn(
+            getHostConfiguredTarget("//java/com/server")
+                .getProvider(RunfilesProvider.class)
+                .getDefaultRunfiles()
+                .getAllArtifacts());
+    assertThat(runfiles)
+        .containsAllIn(
+            getHostConfiguredTarget(
+                    androidInstrumentationTest
+                        .getTarget()
+                        .getAssociatedRule()
+                        .getAttrDefaultValue("$test_entry_point")
+                        .toString())
+                .getProvider(RunfilesProvider.class)
+                .getDefaultRunfiles()
+                .getAllArtifacts());
+    assertThat(runfiles)
+        .containsAllOf(
+            getDeviceFixtureScript(getConfiguredTarget("//javatests/com/app:device_fixture")),
+            getInstrumentationApk(getConfiguredTarget("//javatests/com/app:instrumentation1")),
+            getTargetApk(getConfiguredTarget("//javatests/com/app:instrumentation1")),
+            getInstrumentationApk(getConfiguredTarget("//javatests/com/app:instrumentation2")),
+            getTargetApk(getConfiguredTarget("//javatests/com/app:instrumentation2")),
+            Iterables.getOnlyElement(
+                getConfiguredTarget("//javatests/com/app/ait:foo.txt")
+                    .getProvider(FileProvider.class)
+                    .getFilesToBuild()));
+  }
+
+  @Test
+  public void testTestExecutableContents() throws Exception {
+    ConfiguredTarget androidInstrumentationTest = getConfiguredTarget("//javatests/com/app/ait");
+    assertThat(androidInstrumentationTest).isNotNull();
+
+    String testExecutableScript =
+        ((TemplateExpansionAction)
+                getGeneratingAction(
+                    androidInstrumentationTest
+                        .getProvider(FilesToRunProvider.class)
+                        .getExecutable()))
+            .getFileContents();
+
+    assertThat(testExecutableScript)
+        .contains(
+            "instrumentation_apks=\"javatests/com/app/instrumentation1-instrumentation.apk "
+                + "javatests/com/app/instrumentation2-instrumentation.apk\"");
+    assertThat(testExecutableScript)
+        .contains(
+            "target_apks=\"javatests/com/app/instrumentation1-target.apk "
+                + "javatests/com/app/instrumentation2-target.apk\"");
+    assertThat(testExecutableScript).contains("support_apks=\"java/com/app/support.apk\"");
+    assertThat(testExecutableScript)
+        .contains(
+            "declare -A device_script_fixtures=( "
+                + "[javatests/com/app/cmd_device_fixtures/device_fixture/cmd.sh]=false,true )");
+    assertThat(testExecutableScript).contains("host_service_fixture=\"java/com/server/server\"");
+    assertThat(testExecutableScript).contains("host_service_fixture_services=\"foo,bar\"");
+    assertThat(testExecutableScript)
+        .contains("device_script=\"${WORKSPACE_DIR}/tools/android/emulated_device/nexus_6\"");
+    assertThat(testExecutableScript).contains("data_deps=\"javatests/com/app/ait/foo.txt\"");
+  }
+
+  @Test
+  public void testAtMostOneHostServiceFixture() throws Exception {
+    checkError(
+        "javatests/com/app/ait2",
+        "ait",
+        "android_instrumentation_test accepts at most one android_host_service_fixture",
+        "android_host_service_fixture(",
+        "  name = 'host_fixture',",
+        "  executable = '//java/com/server',",
+        "  service_names = ['foo', 'bar'],",
+        ")",
+        "android_instrumentation_test(",
+        "  name = 'ait',",
+        "  instrumentations = ['//javatests/com/app:instrumentation1'],",
+        "  target_device = '//tools/android/emulated_device:nexus_6',",
+        "  fixtures = [",
+        "    ':host_fixture',",
+        "    '//javatests/com/app:host_fixture',",
+        "  ],",
+        ")");
+  }
+
+  private static Artifact getDeviceFixtureScript(ConfiguredTarget deviceScriptFixture) {
+    return getFirstArtifactEndingWith(
+        deviceScriptFixture.getProvider(FileProvider.class).getFilesToBuild(), ".sh");
+  }
+
+  private static Artifact getInstrumentationApk(ConfiguredTarget instrumentation) {
+    return ((AndroidInstrumentationInfoProvider)
+            instrumentation.get(
+                AndroidInstrumentationInfoProvider.ANDROID_INSTRUMENTATION_INFO.getKey()))
+        .getInstrumentationApk();
+  }
+
+  private static Artifact getTargetApk(ConfiguredTarget instrumentation) {
+    return ((AndroidInstrumentationInfoProvider)
+            instrumentation.get(
+                AndroidInstrumentationInfoProvider.ANDROID_INSTRUMENTATION_INFO.getKey()))
+        .getTargetApk();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/rules/android/BUILD b/src/test/java/com/google/devtools/build/lib/rules/android/BUILD
index 8e732e0..96e6b30 100644
--- a/src/test/java/com/google/devtools/build/lib/rules/android/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/rules/android/BUILD
@@ -129,6 +129,25 @@
 )
 
 java_test(
+    name = "AndroidInstrumentationTestTest",
+    srcs = ["AndroidInstrumentationTestTest.java"],
+    tags = ["no_windows"],
+    deps = [
+        ":AndroidBuildViewTestCase",
+        "//src/main/java/com/google/devtools/build/lib:android-rules",
+        "//src/main/java/com/google/devtools/build/lib:build-base",
+        "//src/main/java/com/google/devtools/build/lib:collect",
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/test/java/com/google/devtools/build/lib:actions_testutil",
+        "//src/test/java/com/google/devtools/build/lib:analysis_testutil",
+        "//src/test/java/com/google/devtools/build/lib:testutil",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
     name = "AndroidLibraryTest",
     srcs = ["AndroidLibraryTest.java"],
     deps = [