Post `TestAttempt`/`TestResult` events when a test can be built but test-exec-config-inputs fail to build.

Usually, when a test target can be built, the `TestRunnerAction` will be
buildable, but this is not always the case. If the special `exec`-config inputs
fail to build, the test action may fail to build, which will only happen in the
`test` command. The build tool (and BEP) assume that if a test-type target is
buildable, and the command is `test`, then the corresponding
`TestAttempt`/`TestResult` events will eventually be posted.

This change ensures those events are posted with minimal, correct information indicating the test `FAILED_TO_BUILD`.

RELNOTES: BEP will include correct \`TestResult\` and \`TargetSummary\` events when special test inputs like \`$test_runtime\` fail to build.
PiperOrigin-RevId: 660904505
Change-Id: Ie44d558be5393ee910e6c4171ac295af2f34b182
diff --git a/src/main/java/com/google/devtools/build/lib/actions/Artifact.java b/src/main/java/com/google/devtools/build/lib/actions/Artifact.java
index af2020d..87f8d5f 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/Artifact.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/Artifact.java
@@ -198,20 +198,20 @@
     return ((DerivedArtifact) artifact).getGeneratingActionKey();
   }
 
-  public static Iterable<SkyKey> keys(Iterable<Artifact> artifacts) {
-    return artifacts instanceof Collection<Artifact> collection
+  public static <T extends Artifact> Iterable<SkyKey> keys(Iterable<T> artifacts) {
+    return artifacts instanceof Collection<T> collection
         ? keys(collection)
         : Iterables.transform(artifacts, Artifact::key);
   }
 
-  public static Collection<SkyKey> keys(Collection<Artifact> artifacts) {
-    return artifacts instanceof List<Artifact> list
+  public static <T extends Artifact> Collection<SkyKey> keys(Collection<T> artifacts) {
+    return artifacts instanceof List<T> list
         ? keys(list)
         // Use Collections2 instead of Iterables#transform to ensure O(1) size().
         : Collections2.transform(artifacts, Artifact::key);
   }
 
-  public static List<SkyKey> keys(List<Artifact> artifacts) {
+  public static <T extends Artifact> List<SkyKey> keys(List<T> artifacts) {
     return Lists.transform(artifacts, Artifact::key);
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/test/TestAttempt.java b/src/main/java/com/google/devtools/build/lib/analysis/test/TestAttempt.java
index 9153d3e..4a7aacb 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/test/TestAttempt.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/test/TestAttempt.java
@@ -110,6 +110,10 @@
         lastAttempt);
   }
 
+  /**
+   * Creates a test attempt result from cached test data, providing a result while indicating to
+   * consumers that the test did not actually execute.
+   */
   public static TestAttempt fromCachedTestResult(
       TestRunnerAction testAction,
       TestResultData attemptData,
@@ -131,6 +135,29 @@
         lastAttempt);
   }
 
+  /**
+   * Creates a test result for rare cases where the test itself was built, but the {@link
+   * TestRunnerAction} could not be started by a test strategy.
+   *
+   * <p>This overload should be very rarely used, and in particular must not be used by an
+   * implementation of a {@link TestStrategy}.
+   */
+  public static TestAttempt forUnstartableTestResult(
+      TestRunnerAction testAction, TestResultData attemptData) {
+    return new TestAttempt(
+        false,
+        testAction,
+        /* executionInfo= */ BuildEventStreamProtos.TestResult.ExecutionInfo.getDefaultInstance(),
+        /* attempt= */ 1,
+        attemptData.getStatus(),
+        attemptData.getStatusDetails(),
+        attemptData.getStartTimeMillisEpoch(),
+        attemptData.getRunDurationMillis(),
+        /* files= */ ImmutableList.of(),
+        attemptData.getWarningList(),
+        /* lastAttempt= */ true);
+  }
+
   @VisibleForTesting
   public Artifact getTestStatusArtifact() {
     return testAction.getCacheStatusArtifact();
@@ -205,7 +232,10 @@
         // TODO(b/199940216): Can we populate metadata for these files?
         localFiles.add(
             new LocalFile(
-                file.getSecond(), localFileType, /*artifact=*/ null, /*artifactMetadata=*/ null));
+                file.getSecond(),
+                localFileType,
+                /* artifact= */ null,
+                /* artifactMetadata= */ null));
       }
     }
     return localFiles.build();
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
index 8c9a159..7bf8943 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/BUILD
@@ -356,6 +356,7 @@
         "//src/main/java/com/google/devtools/build/lib/util:TestType",
         "//src/main/java/com/google/devtools/build/lib/util:abrupt_exit_exception",
         "//src/main/java/com/google/devtools/build/lib/util:detailed_exit_code",
+        "//src/main/java/com/google/devtools/build/lib/util:exit_code",
         "//src/main/java/com/google/devtools/build/lib/util:heap_offset_helper",
         "//src/main/java/com/google/devtools/build/lib/util:resource_usage",
         "//src/main/java/com/google/devtools/build/lib/util:string",
@@ -370,6 +371,7 @@
         "//src/main/java/net/starlark/java/syntax",
         "//src/main/protobuf:failure_details_java_proto",
         "//src/main/protobuf:memory_pressure_java_proto",
+        "//src/main/protobuf:test_status_java_proto",
         "//third_party:auto_value",
         "//third_party:caffeine",
         "//third_party:flogger",
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/TestCompletionFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/TestCompletionFunction.java
index 8b482fa..c31846a 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/TestCompletionFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/TestCompletionFunction.java
@@ -13,17 +13,28 @@
 // limitations under the License.
 package com.google.devtools.build.lib.skyframe;
 
-import com.google.common.collect.Iterables;
+import com.google.devtools.build.lib.actions.ActionExecutionException;
+import com.google.devtools.build.lib.actions.ActionLookupData;
+import com.google.devtools.build.lib.actions.ActionLookupValue;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.analysis.ConfiguredTarget;
 import com.google.devtools.build.lib.analysis.ConfiguredTargetValue;
 import com.google.devtools.build.lib.analysis.TopLevelArtifactContext;
+import com.google.devtools.build.lib.analysis.test.TestAttempt;
 import com.google.devtools.build.lib.analysis.test.TestProvider;
+import com.google.devtools.build.lib.analysis.test.TestResult;
+import com.google.devtools.build.lib.analysis.test.TestRunnerAction;
 import com.google.devtools.build.lib.cmdline.Label;
-import com.google.devtools.build.skyframe.GraphTraversingHelper;
+import com.google.devtools.build.lib.server.FailureDetails.Execution.Code;
+import com.google.devtools.build.lib.util.DetailedExitCode;
+import com.google.devtools.build.lib.util.ExitCode;
+import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
+import com.google.devtools.build.lib.view.test.TestStatus.TestResultData;
 import com.google.devtools.build.skyframe.SkyFunction;
 import com.google.devtools.build.skyframe.SkyKey;
 import com.google.devtools.build.skyframe.SkyValue;
+import com.google.devtools.build.skyframe.SkyframeLookupResult;
+import java.util.List;
 import javax.annotation.Nullable;
 
 /**
@@ -39,7 +50,7 @@
         (TestCompletionValue.TestCompletionKey) skyKey.argument();
     ConfiguredTargetKey ctKey = key.configuredTargetKey();
     TopLevelArtifactContext ctx = key.topLevelArtifactContext();
-    if (env.getValue(TargetCompletionValue.key(ctKey, ctx, /*willTest=*/ true)) == null) {
+    if (env.getValue(TargetCompletionValue.key(ctKey, ctx, /* willTest= */ true)) == null) {
       return null;
     }
 
@@ -58,13 +69,27 @@
         }
       }
     } else {
-      if (GraphTraversingHelper.declareDependenciesAndCheckIfValuesMissingMaybeWithExceptions(
-          env,
-          Iterables.transform(
-              TestProvider.getTestStatusArtifacts(ct),
-              Artifact.DerivedArtifact::getGeneratingActionKey))) {
+      List<SkyKey> skyKeys = Artifact.keys(TestProvider.getTestStatusArtifacts(ct));
+      SkyframeLookupResult result = env.getValuesAndExceptions(skyKeys);
+      if (env.valuesMissing()) {
         return null;
       }
+      for (SkyKey actionKey : skyKeys) {
+        try {
+          if (result.getOrThrow(actionKey, ActionExecutionException.class) == null) {
+            return null;
+          }
+        } catch (ActionExecutionException e) {
+          DetailedExitCode detailedExitCode = e.getDetailedExitCode();
+          if (detailedExitCode.getExitCode().equals(ExitCode.BUILD_FAILURE)
+              && ctValue instanceof ActionLookupValue actionLookupValue) {
+            postTestResultEventsForUnbuildableTestInputs(
+                env, (ActionLookupData) actionKey, actionLookupValue, detailedExitCode);
+          } else {
+            return null;
+          }
+        }
+      }
     }
     return TestCompletionValue.TEST_COMPLETION_MARKER;
   }
@@ -73,4 +98,42 @@
   public String extractTag(SkyKey skyKey) {
     return Label.print(((ConfiguredTargetKey) skyKey.argument()).getLabel());
   }
+
+  /**
+   * Posts events for test actions that could not run because one or more exec-configuration inputs
+   * common to all tests failed to build.
+   *
+   * <p>When we run this SkyFunction we will have already built the test executable and its inputs,
+   * but we might fail to build the exec-configuration attributes providing inputs to the {@link
+   * TestRunnerAction} such as {@code $test_runtime}, {@code $test_wrapper}, {@code
+   * test_setup_script} and others (see {@link
+   * com.google.devtools.build.lib.analysis.BaseRuleClasses.TestBaseRule#build(com.google.devtools.build.lib.packages.RuleClass.Builder,
+   * com.google.devtools.build.lib.analysis.RuleDefinitionEnvironment)} where all Test-type rules
+   * have additional attributes added).
+   *
+   * <p>When these exec-configuration inputs cannot be built, we do not get to use any {@code
+   * TestStrategy} that is responsible for posting {@link TestAttempt} and {@link TestResult}
+   * events. We need to handle this case here and post minimal events indicating the test {@link
+   * BlazeTestStatus.FAILED_TO_BUILD FAILED_TO_BUILD}.
+   */
+  private static void postTestResultEventsForUnbuildableTestInputs(
+      Environment env,
+      ActionLookupData actionKey,
+      ActionLookupValue actionLookupValue,
+      DetailedExitCode detailedExitCode) {
+    BlazeTestStatus status = BlazeTestStatus.FAILED_TO_BUILD;
+    if (detailedExitCode
+        .getFailureDetail()
+        .getExecution()
+        .getCode()
+        .equals(Code.ACTION_NOT_UP_TO_DATE)) {
+      status = BlazeTestStatus.NO_STATUS;
+    }
+    TestRunnerAction testRunnerAction =
+        (TestRunnerAction) actionLookupValue.getAction(actionKey.getActionIndex());
+    TestResultData testData = TestResultData.newBuilder().setStatus(status).build();
+    env.getListener().post(TestAttempt.forUnstartableTestResult(testRunnerAction, testData));
+    env.getListener()
+        .post(new TestResult(testRunnerAction, testData, /* cached= */ false, detailedExitCode));
+  }
 }
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 9d0744b..a8fb8d0 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
@@ -484,6 +484,50 @@
         )
         """);
 
+    // Create fake, minimal implementations of test-setup.sh and test-xml-generator.sh for test
+    // cases that actually execute tests. Does not support coverage, interruption, signals, etc.
+    // For proper test execution support, the actual test-setup.sh will need to be included in the
+    // Java test's runfiles and copied/symlinked into the MockToolsConfig's workspace.
+    config
+        .create(
+            "embedded_tools/tools/test/test-setup.sh",
+            """
+            #!/bin/bash
+            set -e
+            function is_absolute {
+              [[ "$1" = /* ]] || [[ "$1" =~ ^[a-zA-Z]:[/\\].* ]]
+            }
+            is_absolute "$TEST_SRCDIR" || TEST_SRCDIR="$PWD/$TEST_SRCDIR"
+            RUNFILES_MANIFEST_FILE="${TEST_SRCDIR}/MANIFEST"
+            cd ${TEST_SRCDIR}
+            function rlocation() {
+              if is_absolute "$1" ; then
+                # If the file path is already fully specified, simply return it.
+                echo "$1"
+              elif [[ -e "$TEST_SRCDIR/$1" ]]; then
+                # If the file exists in the $TEST_SRCDIR then just use it.
+                echo "$TEST_SRCDIR/$1"
+              elif [[ -e "$RUNFILES_MANIFEST_FILE" ]]; then
+                # If a runfiles manifest file exists then use it.
+                echo "$(grep "^$1 " "$RUNFILES_MANIFEST_FILE" | sed 's/[^ ]* //')"
+              fi
+            }
+
+            EXE="${1#./}"
+            shift
+
+            if is_absolute "$EXE"; then
+              TEST_PATH="$EXE"
+            else
+              TEST_PATH="$(rlocation $TEST_WORKSPACE/$EXE)"
+            fi
+            exec $TEST_PATH
+            """)
+        .chmod(0755);
+    config
+        .create("embedded_tools/tools/test/test-xml-generator.sh", "#!/bin/sh", "cp \"$1\" \"$2\"")
+        .chmod(0755);
+
     // Use an alias package group to allow for modification at the simpler path
     config.create(
         "embedded_tools/tools/allowlists/config_feature_flag/BUILD",
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
index d17b529..eebef3d 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
@@ -560,6 +560,30 @@
 )
 
 java_test(
+    name = "TargetSummaryEventTest",
+    srcs = ["TargetSummaryEventTest.java"],
+    data = ["//src/test/java/com/google/devtools/build/lib:embedded_scripts"],
+    tags = [
+        "no_windows",
+    ],
+    deps = [
+        "//src/main/java/com/google/devtools/build/lib:runtime",
+        "//src/main/java/com/google/devtools/build/lib/actions",
+        "//src/main/java/com/google/devtools/build/lib/authandtls/credentialhelper:credential_module",
+        "//src/main/java/com/google/devtools/build/lib/buildeventservice",
+        "//src/main/java/com/google/devtools/build/lib/buildeventstream/proto:build_event_stream_java_proto",
+        "//src/main/java/com/google/devtools/build/lib/vfs",
+        "//src/test/java/com/google/devtools/build/lib/analysis/util",
+        "//src/test/java/com/google/devtools/build/lib/buildtool/util",
+        "//src/test/java/com/google/devtools/build/lib/testutil",
+        "//third_party:guava",
+        "//third_party:jsr305",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
     name = "BuildResultTestCase",
     srcs = ["BuildResultTestCase.java"],
     shard_count = 4,
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/TargetSummaryEventTest.java b/src/test/java/com/google/devtools/build/lib/buildtool/TargetSummaryEventTest.java
new file mode 100644
index 0000000..c02265b
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/TargetSummaryEventTest.java
@@ -0,0 +1,224 @@
+// Copyright 2024 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.buildtool;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.actions.BuildFailedException;
+import com.google.devtools.build.lib.analysis.util.AnalysisMock;
+import com.google.devtools.build.lib.authandtls.credentialhelper.CredentialModule;
+import com.google.devtools.build.lib.buildeventservice.BazelBuildEventServiceModule;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.BuildEvent;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.BuildEventId.IdCase;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.TestStatus;
+import com.google.devtools.build.lib.buildtool.util.BuildIntegrationTestCase;
+import com.google.devtools.build.lib.runtime.BlazeCommandDispatcher;
+import com.google.devtools.build.lib.runtime.BlazeRuntime;
+import com.google.devtools.build.lib.runtime.NoSpawnCacheModule;
+import com.google.devtools.build.lib.testutil.BlazeTestUtils;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Integration test verifying behavior of {@code
+ * com.google.devtools.build.lib.runtime.TargetSummaryEvent} event.
+ */
+@RunWith(JUnit4.class)
+public class TargetSummaryEventTest extends BuildIntegrationTestCase {
+
+  @Rule public final TemporaryFolder tmpFolder = new TemporaryFolder();
+
+  @Before
+  public void stageEmbeddedTools() throws Exception {
+    AnalysisMock.get().setupMockToolsRepository(mockToolsConfig);
+  }
+
+  @Override
+  protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception {
+    return super.getRuntimeBuilder()
+        .addBlazeModule(new NoSpawnCacheModule())
+        .addBlazeModule(new CredentialModule())
+        .addBlazeModule(new BazelBuildEventServiceModule());
+  }
+
+  private void afterBuildCommand() throws Exception {
+    runtimeWrapper.newCommand();
+  }
+
+  @Test
+  public void plainTarget_buildSuccess() throws Exception {
+    write("foo/BUILD", "genrule(name = 'foobin', outs = ['out.txt'], cmd = 'echo -n Hello > $@')");
+
+    File bep = buildTargetAndCaptureBuildEventProtocol("//foo:foobin");
+
+    BuildEventStreamProtos.TargetSummary summary = findTargetSummaryEventInBuildEventStream(bep);
+    assertThat(summary.getOverallBuildSuccess()).isTrue();
+    assertThat(summary.getOverallTestStatus()).isEqualTo(TestStatus.NO_STATUS);
+  }
+
+  @Test
+  public void plainTarget_buildFails() throws Exception {
+    write("foo/BUILD", "genrule(name = 'foobin', outs = ['out.txt'], cmd = 'false')");
+
+    File bep = buildFailingTargetAndCaptureBuildEventProtocol("//foo:foobin");
+
+    BuildEventStreamProtos.TargetSummary summary = findTargetSummaryEventInBuildEventStream(bep);
+    assertThat(summary.getOverallBuildSuccess()).isFalse();
+    assertThat(summary.getOverallTestStatus()).isEqualTo(TestStatus.NO_STATUS);
+  }
+
+  @Test
+  public void test_buildSucceeds_testSucceeds() throws Exception {
+    write("foo/good_test.sh", "#!/bin/bash", "true").setExecutable(true);
+    write("foo/BUILD", "sh_test(name = 'good_test', srcs = ['good_test.sh'])");
+
+    File bep = testTargetAndCaptureBuildEventProtocol("//foo:good_test");
+
+    BuildEventStreamProtos.TargetSummary summary = findTargetSummaryEventInBuildEventStream(bep);
+    assertThat(summary.getOverallBuildSuccess()).isTrue();
+    assertThat(summary.getOverallTestStatus()).isEqualTo(TestStatus.PASSED);
+  }
+
+  @Test
+  public void test_buildSucceeds_testFails() throws Exception {
+    write("foo/bad_test.sh", "#!/bin/bash", "false").setExecutable(true);
+    write("foo/BUILD", "sh_test(name = 'bad_test', srcs = ['bad_test.sh'])");
+
+    File bep = testTargetAndCaptureBuildEventProtocol("//foo:bad_test");
+
+    BuildEventStreamProtos.TargetSummary summary = findTargetSummaryEventInBuildEventStream(bep);
+    assertThat(summary.getOverallBuildSuccess()).isTrue();
+    assertThat(summary.getOverallTestStatus()).isEqualTo(TestStatus.FAILED);
+  }
+
+  @Test
+  public void test_buildSucceeds_testRuntimeFailsToBuild() throws Exception {
+    write("foo/good_test.sh", "#!/bin/bash", "true").setExecutable(true);
+    write("foo/BUILD", "sh_test(name = 'good_test', srcs = ['good_test.sh'])");
+
+    // Hack: the path to the tools/test/BUILD file is prefixed in the Bazel tests.
+    String pathToToolsTestBuildPrefix = AnalysisMock.get().isThisBazel() ? "embedded_tools/" : "";
+    Path toolsTestBuildPath =
+        mockToolsConfig.getPath(pathToToolsTestBuildPrefix + "tools/test/BUILD");
+    // Delete the test-setup.sh file and introduce a broken genrule to create test-setup.sh.
+    mockToolsConfig.getPath(pathToToolsTestBuildPrefix + "tools/test/test-setup.sh").delete();
+    String bogusTestSetupGenrule =
+        """
+        genrule(
+            name = 'bogus-make-test-setup',
+            outs = ['test-setup.sh'],
+            cmd = 'false',
+        )
+        """;
+    FileSystemUtils.appendIsoLatin1(toolsTestBuildPath, bogusTestSetupGenrule);
+
+    File bep = testTargetAndCaptureBuildEventProtocol("//foo:good_test");
+
+    BuildEventStreamProtos.TargetSummary summary = findTargetSummaryEventInBuildEventStream(bep);
+    assertThat(summary.getOverallBuildSuccess()).isTrue();
+    assertThat(summary.getOverallTestStatus()).isEqualTo(TestStatus.FAILED_TO_BUILD);
+  }
+
+  private File buildTargetAndCaptureBuildEventProtocol(String target) throws Exception {
+    File bep = tmpFolder.newFile();
+    // We use WAIT_FOR_UPLOAD_COMPLETE because it's the easiest way to force the BES module to
+    // wait until the BEP binary file has been written.
+    addOptions(
+        "--keep_going",
+        "--experimental_bep_target_summary",
+        "--build_event_binary_file=" + bep.getAbsolutePath(),
+        "--bes_upload_mode=WAIT_FOR_UPLOAD_COMPLETE");
+    buildTarget(target);
+    // We need to wait for all events to be written to the file, which is done in #afterCommand()
+    // if --bes_upload_mode=WAIT_FOR_UPLOAD_COMPLETE.
+    afterBuildCommand();
+    return bep;
+  }
+
+  private File buildFailingTargetAndCaptureBuildEventProtocol(String target) throws Exception {
+    File bep = tmpFolder.newFile();
+    // We use WAIT_FOR_UPLOAD_COMPLETE because it's the easiest way to force the BES module to
+    // wait until the BEP binary file has been written.
+    addOptions(
+        "--keep_going",
+        "--experimental_bep_target_summary",
+        "--build_event_binary_file=" + bep.getAbsolutePath(),
+        "--bes_upload_mode=WAIT_FOR_UPLOAD_COMPLETE");
+    assertThrows(BuildFailedException.class, () -> buildTarget(target));
+    // We need to wait for all events to be written to the file, which is done in #afterCommand()
+    // if --bes_upload_mode=WAIT_FOR_UPLOAD_COMPLETE.
+    afterBuildCommand();
+    return bep;
+  }
+
+  private File testTargetAndCaptureBuildEventProtocol(String target) throws Exception {
+    File bep = tmpFolder.newFile();
+    BlazeCommandDispatcher dispatcher = new BlazeCommandDispatcher(getRuntime());
+    ImmutableList.Builder<String> args = ImmutableList.builder();
+    args.add("test", target);
+    args.addAll(getDefaultBlazeTestArguments());
+    // We use WAIT_FOR_UPLOAD_COMPLETE because it's the easiest way to force the BES module to
+    // wait until the BEP binary file has been written.
+    args.add(
+        "--keep_going",
+        "--client_env=PATH=/bin:/usr/bin:/usr/sbin:/sbin",
+        "--experimental_bep_target_summary",
+        "--build_event_binary_file=" + bep.getAbsolutePath(),
+        "--bes_upload_mode=WAIT_FOR_UPLOAD_COMPLETE");
+    dispatcher.exec(args.build(), /* clientDescription= */ "test", outErr);
+    return bep;
+  }
+
+  protected List<String> getDefaultBlazeTestArguments() {
+    return BlazeTestUtils.makeArgs("--default_visibility=public", "--test_output=all");
+  }
+
+  private static ImmutableList<BuildEvent> parseBuildEventsFromBuildEventStream(File bep)
+      throws IOException {
+    ImmutableList.Builder<BuildEvent> buildEvents = ImmutableList.builder();
+    try (InputStream in = new FileInputStream(bep)) {
+      BuildEvent ev;
+      while ((ev = BuildEvent.parseDelimitedFrom(in)) != null) {
+        buildEvents.add(ev);
+      }
+    }
+    return buildEvents.build();
+  }
+
+  @Nullable
+  private static BuildEventStreamProtos.TargetSummary findTargetSummaryEventInBuildEventStream(
+      File bep) throws IOException {
+    for (BuildEvent buildEvent : parseBuildEventsFromBuildEventStream(bep)) {
+      if (buildEvent.getId().getIdCase() == IdCase.TARGET_SUMMARY) {
+        return buildEvent.getTargetSummary();
+      }
+    }
+    return null;
+  }
+}