Test shard summary: Include FAILED_TO_BULD targets (#1952)

Previously shard summaries would only show actual test failures.

Example: https://buildkite.com/bazel/bazel-bazel-macos-ninja/builds/1507
(For an older version of this PR - with the most recent version the text
will be `in 1 out of 1 run(s):`) .
diff --git a/buildkite/bazelci.py b/buildkite/bazelci.py
index b199461..10b4158 100755
--- a/buildkite/bazelci.py
+++ b/buildkite/bazelci.py
@@ -350,7 +350,7 @@
     "rules_rust": {
         "git_repository": "https://github.com/bazelbuild/rules_rust.git",
         "pipeline_slug": "rules-rust-rustlang",
-        "disabled_reason": "https://github.com/bazelbuild/rules_rust/issues/2519, https://github.com/bazelbuild/rules_rust/issues/2464"
+        "disabled_reason": "https://github.com/bazelbuild/rules_rust/issues/2519, https://github.com/bazelbuild/rules_rust/issues/2464",
     },
     "rules_scala": {
         "git_repository": "https://github.com/bazelbuild/rules_scala.git",
@@ -3970,9 +3970,8 @@
     def get_details(self):
         overall, bad_runs, total_runs = self._get_detailed_overall_status()
         qualifier = "" if not bad_runs else f"{bad_runs} out of "
-        return overall, (
-            f"in {qualifier}{total_runs} runs over {format_millis(self.attempt_millis)}"
-        )
+        time = f" over {format_millis(self.attempt_millis)}" if self.attempt_millis else ""
+        return overall, (f"in {qualifier}{total_runs} run(s){time}")
 
     @property
     def overall_status(self):
@@ -4033,7 +4032,10 @@
 
         def format_shard(s):
             overall, statistics = s.get_details()
-            return f"{format_test_status(overall)} {statistics}: [log]({get_log_url_for_shard(s)})"
+            # There are no per-target log files for FAILED_TO_BUILD tests, so we simply
+            # link to the step's test log in this case.
+            log = f"#{job_id}" if overall == "FAILED_TO_BUILD" else get_log_url_for_shard(s)
+            return f"{format_test_status(overall)} {statistics}: [log]({log})"
 
         failing_shards = [s for s in self.shards if s.overall_status != "PASSED"]
         if len(failing_shards) == 1:
@@ -4070,28 +4072,36 @@
 def get_test_results_from_bep(path):
     with open(path, "rt") as f:
         for line in f:
-            if "testResult" not in line:
-                # TODO: also look at targetCompleted events that don't have
-                # a matching testResult event, since these are FAILED_TO_BUILD
-                continue
-
             data = json.loads(line)
-            meta = data.get("id", {}).get("testResult")
-            if not meta:
-                continue
+            entry = data.get("id", {})
+            test_result = entry.get("testResult")
+            target_completed = entry.get("targetCompleted")
 
-            if "testResult" not in data:
-                # No testResult field means "aborted" -> NO_STATUS
-                # TODO: show these targets in the UI?
-                continue
+            if test_result:
+                if "testResult" not in data:
+                    # No testResult field means "aborted" -> NO_STATUS
+                    # TODO: show these targets in the UI?
+                    continue
 
-            yield (
-                meta["label"],
-                meta["shard"],
-                meta["attempt"],
-                data["testResult"]["status"],
-                int(data["testResult"]["testAttemptDurationMillis"]),
-            )
+                yield (
+                    test_result["label"],
+                    test_result["shard"],
+                    test_result["attempt"],
+                    data["testResult"]["status"],
+                    int(data["testResult"]["testAttemptDurationMillis"]),
+                )
+            elif target_completed:
+                # There are no "testResult" events for targets that fail to build,
+                # so we have to check for targetCompleted events that have a failureDetail
+                # message.
+                if data.get("completed", {}).get("failureDetail"):
+                    yield (
+                        target_completed["label"],
+                        1,
+                        1,
+                        "FAILED_TO_BUILD",
+                        0,
+                    )
 
 
 def upload_bazel_binaries():