pw_unit_test: Support ResultDB upload
Produces more structured MILO UI results on test failures. This is just
an MVP; we should improve it down the road.
Bug: 247857184
Change-Id: Ic31b39b4e24d0daff99311b72048044eaf89637e
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/111472
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Ted Pudlik <tpudlik@google.com>
diff --git a/pw_unit_test/py/pw_unit_test/test_runner.py b/pw_unit_test/py/pw_unit_test/test_runner.py
index 420c9a8..4dc263d 100644
--- a/pw_unit_test/py/pw_unit_test/test_runner.py
+++ b/pw_unit_test/py/pw_unit_test/test_runner.py
@@ -15,22 +15,34 @@
import argparse
import asyncio
+import base64
import enum
import json
import logging
import os
+import re
import subprocess
import sys
+import time
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
+import requests
+
import pw_cli.log
import pw_cli.process
# Global logger for the script.
_LOG: logging.Logger = logging.getLogger(__name__)
+_ANSI_SEQUENCE_REGEX = re.compile(rb'\x1b[^m]*m')
+
+
+def _strip_ansi(bytes_with_sequences: bytes) -> bytes:
+ """Strip out ANSI escape sequences."""
+ return _ANSI_SEQUENCE_REGEX.sub(b'', bytes_with_sequences)
+
def register_arguments(parser: argparse.ArgumentParser) -> None:
"""Registers command-line arguments."""
@@ -73,10 +85,11 @@
class Test:
"""A unit test executable."""
- def __init__(self, name: str, file_path: str):
+ def __init__(self, name: str, file_path: str) -> None:
self.name: str = name
self.file_path: str = file_path
self.status: TestResult = TestResult.UNKNOWN
+ self.duration_s: float
def __repr__(self) -> str:
return f'Test({self.name})'
@@ -130,12 +143,21 @@
executable: str,
args: Sequence[str],
tests: Iterable[Test],
- timeout: Optional[float] = None):
+ timeout: Optional[float] = None) -> None:
self._executable: str = executable
self._args: Sequence[str] = args
self._tests: List[Test] = list(tests)
self._timeout = timeout
+ # Access go/result-sink, if available.
+ ctx_path = Path(os.environ.get("LUCI_CONTEXT", ''))
+ if not ctx_path.is_file():
+ return
+
+ ctx = json.loads(ctx_path.read_text(encoding='utf-8'))
+ self._result_sink: Optional[Dict[str,
+ str]] = ctx.get('result_sink', None)
+
async def run_tests(self) -> None:
"""Runs all registered unit tests through the runner script."""
@@ -155,30 +177,92 @@
if self._executable.endswith('.py'):
command.insert(0, sys.executable)
+ start_time = time.monotonic()
try:
process = await pw_cli.process.run_async(*command,
timeout=self._timeout)
- if process.returncode == 0:
- test.status = TestResult.SUCCESS
- test_result = 'PASS'
- else:
- test.status = TestResult.FAILURE
- test_result = 'FAIL'
-
- _LOG.log(pw_cli.log.LOGLEVEL_STDOUT, '[%s]\n%s',
- pw_cli.color.colors().bold_white(process.pid),
- process.output.decode(errors='ignore').rstrip())
-
- _LOG.info('%s: [%s] %s', test_counter, test_result,
- test.name)
except subprocess.CalledProcessError as err:
_LOG.error(err)
return
+ test.duration_s = time.monotonic() - start_time
+
+ if process.returncode == 0:
+ test.status = TestResult.SUCCESS
+ test_result = 'PASS'
+ else:
+ test.status = TestResult.FAILURE
+ test_result = 'FAIL'
+
+ _LOG.log(pw_cli.log.LOGLEVEL_STDOUT, '[Pid: %s]\n%s',
+ pw_cli.color.colors().bold_white(process.pid),
+ process.output.decode(errors='ignore').rstrip())
+
+ _LOG.info('%s: [%s] %s in %.3f s', test_counter, test_result,
+ test.name, test.duration_s)
+
+ try:
+ self._maybe_upload_to_resultdb(test, process)
+ except requests.exceptions.HTTPError as err:
+ _LOG.error(err)
+ return
def all_passed(self) -> bool:
"""Returns true if all unit tests passed."""
return all(test.status is TestResult.SUCCESS for test in self._tests)
+ def _maybe_upload_to_resultdb(self, test: Test,
+ process: pw_cli.process.CompletedProcess):
+ """Uploads test result to ResultDB, if available."""
+ if self._result_sink is None:
+ # ResultDB integration not enabled.
+ return
+
+ test_result = {
+ # The test.name is not suitable as an identifier because it's just
+ # the basename of the test (channel_test). We want the full path,
+ # including the toolchain used.
+ "testId": test.file_path,
+ # ResultDB also supports CRASH and ABORT, but there's currently no
+ # way to distinguish these in pw_unit_test.
+ "status": "PASS" if test.status is TestResult.SUCCESS else "FAIL",
+ # The "expected" field is required. It could be used to report
+ # expected failures, but we don't currently support these in
+ # pw_unit_test.
+ "expected": test.status is TestResult.SUCCESS,
+ # Ensure to format the duration with '%.9fs' to avoid scientific
+ # notation. If a value is too large or small and formatted with
+ # str() or '%s', python formats the value in scientific notation,
+ # like '1.1e-10', which is an invalid input for
+ # google.protobuf.duration.
+ "duration": "%.9fs" % test.duration_s,
+ "summaryHtml":
+ '<p><text-artifact artifact-id="artifact-content-in-request"></p>',
+ "artifacts": {
+ "artifact-content-in-request": {
+ # Need to decode the bytes back to ASCII or they will not be
+ # encodable by json.dumps.
+ #
+ # TODO(b/248349219): Instead of stripping the ANSI color
+ # codes, convert them to HTML.
+ "contents":
+ base64.b64encode(_strip_ansi(
+ process.output)).decode('ascii'),
+ },
+ },
+ }
+
+ requests.post(
+ url='http://%s/prpc/luci.resultsink.v1.Sink/ReportTestResults' %
+ self._result_sink['address'],
+ headers={
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'Authorization':
+ 'ResultSink %s' % self._result_sink['auth_token'],
+ },
+ data=json.dumps({'testResults': [test_result]}),
+ ).raise_for_status()
+
# Filename extension for unit test metadata files.
METADATA_EXTENSION = '.testinfo.json'
diff --git a/pw_unit_test/py/setup.cfg b/pw_unit_test/py/setup.cfg
index 27cce25..d0f7b5d 100644
--- a/pw_unit_test/py/setup.cfg
+++ b/pw_unit_test/py/setup.cfg
@@ -22,6 +22,8 @@
packages = find:
zip_safe = False
install_requires =
+ requests
+ types-requests
[options.package_data]
pw_unit_test = py.typed