blob: 847ffa605b60a56e2dc3986b4b41f73d7c500a48 [file] [edit]
# Licensed under the Apache-2.0 license
# SPDX-License-Identifier: Apache-2.0
"""Fail if a `rust_test` target ran zero tests.
`rust_test` reports PASSED when the test binary contains no tests, so a target
whose sources are all stripped by `cfg` looks the same as one that ran.
Reads a Bazel Build Event Protocol JSON file (`--build_event_json_file`) from a
`bazel test` run. BEP names every test's `test.log` whether the result was
cached or not, so this does not depend on the console stream or on where the
`bazel-testlogs` symlink points.
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse
# libtest prints one summary per test binary (unit tests, each doctest run).
_RESULT = re.compile(r"^test result: \w+\. (\d+) passed;", re.MULTILINE)
_RUST_TEST_KIND = "rust_test rule"
# workflows.json sets this (via `--run_under=env`) to the BEP file the `ci_tests`
# build writes under its `${BUILD_ROOT}`; it may be relative to the invoking
# directory.
_BEP_ENV = "ZERO_TESTS_BEP"
# Targets that intentionally have no tests yet. Prefer deleting the target.
_ALLOWLIST = frozenset(
(
"//services/pldm:pldm_service_test",
"//services/spdm/transport-mctp:spdm_transport_mctp_test",
)
)
def passed_count(log: str) -> int:
"""Total tests passed across every libtest summary line in `log`."""
return sum(int(n) for n in _RESULT.findall(log))
def find_zero_test_targets(bep_lines, allowlist=_ALLOWLIST) -> tuple[int, list[str]]:
"""Returns (rust_test results audited, labels that ran zero tests).
Raises ValueError if the stream has no `buildFinished` event, i.e. it is
truncated because the run died part-way.
"""
rust_tests = set()
logs: dict[str, str] = {}
finished = False
for line in bep_lines:
event = json.loads(line)
event_id = event.get("id", {})
if "buildFinished" in event_id:
finished = True
elif "targetConfigured" in event_id:
if event["configured"].get("targetKind") == _RUST_TEST_KIND:
rust_tests.add(event_id["targetConfigured"]["label"])
elif "testResult" in event_id:
# A failed target already fails the bazel invocation; judging its
# passed-count would misreport "ran no tests".
if event["testResult"].get("status") != "PASSED":
continue
for output in event["testResult"].get("testActionOutput", []):
if output["name"] == "test.log":
logs[event_id["testResult"]["label"]] = output["uri"]
if not finished:
raise ValueError("BEP stream is incomplete: the test run did not finish")
audited, zero = 0, []
for label in sorted(rust_tests & logs.keys()):
audited += 1
if label in allowlist:
continue
path = Path(unquote(urlparse(logs[label]).path))
if passed_count(path.read_text(errors="replace")) == 0:
zero.append(label)
return audited, zero
def resolve_bep_path(arg: Path | None, env=os.environ) -> Path | None:
"""Picks the BEP file: explicit argument, then $ZERO_TESTS_BEP, else None.
`bazel run` executes from the runfiles tree, so a relative path is taken
relative to BUILD_WORKING_DIRECTORY, where the user (or pw) invoked it.
"""
if arg is not None:
return arg
if _BEP_ENV in env:
return Path(env.get("BUILD_WORKING_DIRECTORY", ".")) / env[_BEP_ENV]
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"bep",
type=Path,
nargs="?",
help=f"BEP JSON file from bazel test (default: ${_BEP_ENV})",
)
args = parser.parse_args()
args.bep = resolve_bep_path(args.bep)
if args.bep is None:
parser.error(f"no BEP file: pass a path or set {_BEP_ENV}")
try:
with args.bep.open() as f:
audited, zero = find_zero_test_targets(f)
except (OSError, ValueError) as exc:
print(f"cannot read {args.bep}: {exc}", file=sys.stderr)
return 2
for label in zero:
print(f"ZERO TESTS: {label} ran no tests", file=sys.stderr)
print(f"audited {audited} rust_test targets, {len(zero)} ran zero tests")
return 1 if zero else 0
if __name__ == "__main__":
sys.exit(main())