Files
Joachim Wiberg 982e610fef test: infamy: always emit the "1..N" plan line before exiting
Always emit the "1..N" plan line before exiting with error so test
harnesses don't report "test error, no plan" for failed or aborted
tests.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-03-09 19:25:29 +01:00

181 lines
4.9 KiB
Python

"""TAP (Test Anything Protocol) output helpers for Infamy tests.
TAP is a simple line-based protocol for communicating test results between a
test producer and a consumer (harness). The canonical reference is:
https://testanything.org/tap-specification.html
The key elements are:
Plan line: ``1..N`` — total number of tests, written once
Result lines: ``ok N - desc`` — test N passed
``not ok N - desc``— test N failed
Directives: ``ok N # SKIP`` ``not ok N # TODO``
Diagnostics: Any line beginning with ``#`` is a comment/diagnostic.
The plan line may appear either at the beginning (before any results) or at
the end (after all results). Infamy writes it at the END so that the total
step count is known; a harness that requires an upfront plan will report
"no plan" on failure — see CommentWriter below for a known limitation.
``CommentWriter`` redirects Python's ``sys.stdout`` so that ordinary
``print()`` calls are prefixed with ``# `` and appear as diagnostics, while
TAP result lines (written via ``self.out``, the *original* stdout) are emitted
verbatim. The plan line is also written via ``self.out``.
Note: because ``sys.stdout`` is replaced *after* module import the default
argument ``output=sys.stdout`` captures the original stream at import time,
so nested or re-entrant ``Test()`` instances will share the same ``self.out``.
"""
import contextlib
import datetime
import subprocess
import sys
import traceback
import infamy.netns
class Test:
def __init__(self, output=sys.stdout):
self.out = output
self.commenter = CommentWriter(self.out)
if self.out == sys.stdout:
sys.stdout = self.commenter
self.test_cleanup=[]
self.steps = 0
def push_test_cleanup(self, fn):
self.test_cleanup.append(fn)
def cleanup(self):
infamy.netns.IsolatedMacVlans.Cleanup()
for test_cleanup in reversed(self.test_cleanup):
test_cleanup()
def __enter__(self):
now = datetime.datetime.now().strftime("%F %T")
self.out.write(f"# Starting ({now})\n")
self.out.flush()
return self
def __exit__(self, t, e, tb):
now = datetime.datetime.now().strftime("%F %T")
self.out.write(f"# Exiting ({now})\n")
self.out.flush()
self.cleanup()
if not e:
self._not_ok("Missing explicit test result\n")
else:
if t in (TestPass, TestSkip):
self.out.write(f"1..{self.steps}\n")
self.out.flush()
raise SystemExit(0)
if t is AssertionError:
traceback.print_tb(tb, file=self.commenter)
self.out.write(f"{str(e)}\n")
self.out.flush()
else:
traceback.print_exception(e, file=self.commenter)
if type(e) is subprocess.CalledProcessError:
print("Failing subprocess stdout:\n", e.stdout)
elif len(e.args) and type(e.args[0]) is subprocess.CompletedProcess:
print("Failing subprocess stdout:\n", e.args[0].stdout)
self.out.write(f"1..{self.steps}\n")
self.out.flush()
raise SystemExit(1)
@contextlib.contextmanager
def step(self, msg):
try:
yield
self._ok(msg)
except Exception as e:
if type(e) == TestPass:
self._ok(msg)
elif type(e) == TestSkip:
self._ok(directive="skip")
elif type(e) == TestFail:
self._not_ok(msg)
else:
self._not_ok(msg)
raise e
def _report(self, tag, msg):
self.steps += 1
self.out.write(f"{tag} {self.steps}{msg}\n")
self.out.flush()
def _ok(self, msg="", directive=None):
if msg:
msg = " - " + msg
if directive:
msg = msg + " # " + directive
self._report("ok", msg)
def _not_ok(self, msg):
self._report("not ok", " - " + msg)
def succeed(self):
raise TestPass()
def skip(self):
raise TestSkip()
def fail(self, message=None):
if message:
self.out.write(f"# {message}\n")
self.out.flush()
raise TestFail()
class TestResult(Exception):
pass
class TestPass(TestResult):
pass
class TestFail(TestResult):
pass
class TestSkip(TestResult):
pass
class CommentWriter:
def __init__(self, f):
self.f = f
self.at_nl = True
def write(self, data):
if self.at_nl:
data = "# " + data
self.at_nl = False
if not len(data):
return
if data.endswith("\n"):
self.at_nl = True
data = data[:-1]
data = data.replace("\n", "\n# ")
if self.at_nl:
data = data + "\n"
self.f.write(data)
self.flush()
def flush(self):
return self.f.flush()